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 7ab6bf525f..ae5966b20f 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen import chat.simplex.common.model.clear +import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR @@ -75,7 +76,7 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { } else if (chatModel.chatId.value != null) { // Since no modals are open, the problem is probably in ChatView chatModel.chatId.value = null - chatModel.chatItems.clear() + chatModel.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/chat/item/CIVideoView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt index f2f3e27766..a8c084bbad 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt @@ -44,3 +44,10 @@ actual fun LocalWindowWidth(): Dp { (rect.width() / density).dp } } + +@Composable +actual fun LocalWindowHeight(): Dp { + val view = LocalView.current + val density = LocalDensity.current + return with(density) { view.height.toDp() } +} 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 422eb1e77f..ef777f151f 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,10 +8,11 @@ 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.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState @@ -22,6 +23,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.collections.removeAll as remAll import kotlinx.datetime.* import kotlinx.datetime.TimeZone import kotlinx.serialization.* @@ -35,6 +37,7 @@ import java.net.URI import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* +import kotlin.collections.ArrayList import kotlin.random.Random import kotlin.time.* @@ -64,7 +67,14 @@ object ChatModel { // 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) */ val chatItems = mutableStateOf(SnapshotStateList()) + // 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() @@ -216,6 +226,15 @@ object ChatModel { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0) } + private suspend fun reorderChat(chat: Chat, toIndex: Int) { + val newChats = SnapshotStateList() + newChats.addAll(chats.value) + newChats.remove(chat) + newChats.add(index = toIndex, chat) + chats.replaceAll(newChats) + popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = toIndex) + } + fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { @@ -317,7 +336,7 @@ object ChatModel { chat.chatStats ) if (appPlatform.isDesktop && cItem.chatDir.sent) { - addChat(chats.removeAt(i)) + reorderChat(chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } @@ -330,9 +349,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.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem) } else { - chatItems.add(cItem) + chatItems.addAndNotify(cItem) } } } @@ -377,7 +396,7 @@ object ChatModel { } else { cItem } - chatItems.add(ci) + chatItems.addAndNotify(ci) true } } else { @@ -416,7 +435,7 @@ object ChatModel { } // remove from current chat if (chatId.value == cInfo.id) { - chatItems.removeAll { + chatItems.removeAllAndNotify { // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id) val remove = it.id == cItem.id && it.meta.createdAt == cItem.meta.createdAt @@ -436,7 +455,7 @@ object ChatModel { // clear current chat if (chatId.value == cInfo.id) { chatItemStatuses.clear() - chatItems.clear() + chatItems.clearAndNotify() } } @@ -607,14 +626,14 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withContext(Dispatchers.Main) { - chatItems.add(cItem) + chatItems.addAndNotify(cItem) } return cItem } fun removeLiveDummy() { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.removeLast() + chatItems.removeLastAndNotify() } } @@ -622,11 +641,17 @@ object ChatModel { val cInfo = chatInfo var markedRead = 0 if (chatId.value == cInfo.id) { - var i = 0 val items = chatItems.value - while (i < items.size) { + var i = items.lastIndex + val itemIdsFromRange = if (range != null) { + (range.from .. range.to).toMutableSet() + } else { + mutableSetOf() + } + val markedReadIds = mutableSetOf() + while (i >= 0) { val item = items[i] - if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { + if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || itemIdsFromRange.contains(item.id))) { val newItem = item.withStatus(CIStatus.RcvRead()) items[i] = newItem if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { @@ -634,10 +659,17 @@ object ChatModel { deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) ) } + markedReadIds.add(item.id) markedRead++ + if (range != null) { + itemIdsFromRange.remove(item.id) + // already set all needed items as read, can finish the loop + if (itemIdsFromRange.isEmpty()) break + } } - i += 1 + i-- } + chatItemsChangesListener?.read(if (range != null) markedReadIds else null, items) } return markedRead } @@ -684,17 +716,6 @@ object ChatModel { return count to ns } - // returns the index of the passed item and the next item (it has smaller index) - fun getNextChatItem(ci: ChatItem): Pair { - val i = getChatItemIndexOrNull(ci) - return if (i != null) { - val reversedChatItems = chatItems.asReversed() - i to if (i > 0) reversedChatItems[i - 1] else null - } else { - null to null - } - } - // returns the index of the first item in the same merged group (the first hidden item) // and the previous visible item with another merge category fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { @@ -738,7 +759,7 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { showingInvitation.value = null - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() chatModel.chatId.value = withId ModalManager.start.closeModals() ModalManager.end.closeModals() @@ -748,7 +769,7 @@ object ChatModel { fun dismissConnReqView(id: String) { if (id == showingInvitation.value?.connId) { showingInvitation.value = null - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() chatModel.chatId.value = null // Close NewChatView ModalManager.start.closeModals() @@ -798,6 +819,15 @@ object ChatModel { fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } +interface ChatItemsChangesListener { + // pass null itemIds if the whole chat now read + fun read(itemIds: Set?, newItems: List) + fun added(item: Pair, index: Int) + // itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not) + fun removed(itemIds: List>, newItems: List) + fun cleared() +} + data class ShowingInvitation( val connId: String, val connReq: String, @@ -1293,6 +1323,12 @@ data class Contact( } } +@Serializable +data class NavigationInfo( + val afterUnread: Int = 0, + val afterTotal: Int = 0 +) + @Serializable enum class ContactStatus { @SerialName("active") Active, @@ -2279,12 +2315,24 @@ data class ChatItem ( } } -fun MutableState>.add(index: Int, elem: T) { - value = SnapshotStateList().apply { addAll(value); add(index, elem) } +fun MutableState>.add(index: Int, elem: Chat) { + value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.add(elem: T) { - value = SnapshotStateList().apply { addAll(value); add(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>.add(elem: Chat) { + value = SnapshotStateList().apply { addAll(value); add(elem) } +} + +// For some reason, Kotlin version crashes if the list is empty +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>.addAll(index: Int, elems: List) { @@ -2295,28 +2343,59 @@ fun MutableState>.addAll(elems: List) { value = SnapshotStateList().apply { addAll(value); addAll(elems) } } -fun MutableState>.removeAll(block: (T) -> Boolean) { - value = SnapshotStateList().apply { addAll(value); removeAll(block) } +fun MutableState>.removeAll(block: (Chat) -> Boolean) { + value = SnapshotStateList().apply { addAll(value); removeAll(block) } } -fun MutableState>.removeAt(index: Int): T { - val new = SnapshotStateList() +// Removes item(s) from chatItems and notifies a listener about removed item(s) +fun MutableState>.removeAllAndNotify(block: (ChatItem) -> Boolean) { + val toRemove = ArrayList>() + value = SnapshotStateList().apply { + addAll(value) + var i = 0 + removeAll { + val remove = block(it) + if (remove) toRemove.add(Triple(it.id, i, it.isRcvNew)) + i++ + remove + } + } + if (toRemove.isNotEmpty()) { + chatItemsChangesListener?.removed(toRemove, value) + } +} + +fun MutableState>.removeAt(index: Int): Chat { + val new = SnapshotStateList() new.addAll(value) val res = new.removeAt(index) value = new return res } -fun MutableState>.removeLast() { - value = SnapshotStateList().apply { addAll(value); removeLast() } +fun MutableState>.removeLastAndNotify() { + val removed: Triple + value = SnapshotStateList().apply { + addAll(value) + val remIndex = lastIndex + val rem = removeLast() + removed = Triple(rem.id, remIndex, rem.isRcvNew) + } + chatItemsChangesListener?.removed(listOf(removed), value) } fun MutableState>.replaceAll(elems: List) { value = SnapshotStateList().apply { addAll(elems) } } -fun MutableState>.clear() { - value = SnapshotStateList() +fun MutableState>.clear() { + value = SnapshotStateList() +} + +// Removes all chatItems and notifies a listener about it +fun MutableState>.clearAndNotify() { + value = SnapshotStateList() + chatItemsChangesListener?.cleared() } fun State>.asReversed(): MutableList = value.asReversed() 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 5674920914..7f7f8a6e58 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 @@ -22,6 +22,7 @@ import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* @@ -868,11 +869,15 @@ object ChatController { return emptyList() } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) - if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh) + 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}") - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) + if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { + showQuotedItemDoesNotExistAlert() + } else { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) + } return null } @@ -2861,7 +2866,7 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() withChats { chats.clear() popChatCollector.clear() @@ -3423,7 +3428,7 @@ sealed class CC { is GetAgentServersSummary -> "getAgentServersSummary" } - class ItemRange(val from: Long, val to: Long) + data class ItemRange(val from: Long, val to: Long) fun chatItemTTLStr(seconds: Long?): String { if (seconds == null) return "none" @@ -3471,15 +3476,19 @@ sealed class ChatPagination { class Last(val count: Int): ChatPagination() class After(val chatItemId: Long, val count: Int): ChatPagination() class Before(val chatItemId: Long, val count: Int): ChatPagination() + class Around(val chatItemId: Long, val count: Int): ChatPagination() + class Initial(val count: Int): ChatPagination() val cmdString: String get() = when (this) { is Last -> "count=${this.count}" is After -> "after=${this.chatItemId} count=${this.count}" is Before -> "before=${this.chatItemId} count=${this.count}" + is Around -> "around=${this.chatItemId} count=${this.count}" + is Initial -> "initial=${this.count}" } companion object { - const val INITIAL_COUNT = 100 + val INITIAL_COUNT = if (appPlatform.isDesktop) 100 else 75 const val PRELOAD_COUNT = 100 const val UNTIL_PRELOAD_COUNT = 50 } @@ -4917,7 +4926,7 @@ sealed class CR { @Serializable @SerialName("chatRunning") class ChatRunning: CR() @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() - @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat): CR() + @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() @@ -5267,7 +5276,7 @@ sealed class CR { is ChatRunning -> noDetails() is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) - is ApiChat -> withUser(user, json.encodeToString(chat)) + is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index e7c653e1b9..1f1cb45d48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -65,7 +65,7 @@ abstract class NtfManager { } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo) } } 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 d4eb416081..b6eb4c8996 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layoutId @@ -25,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.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -125,11 +127,11 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State) { derivedStateOf { chatModel.terminalItems.value.asReversed() } } val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() + var autoScrollToBottom = rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { - var autoScrollToBottom = listState.firstVisibleItemIndex <= 1 launch { snapshotFlow { listState.layoutInfo.totalItemsCount } - .filter { autoScrollToBottom } + .filter { autoScrollToBottom.value } .collect { try { listState.scrollToItem(0) @@ -138,10 +140,16 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State) { } } } + var oldNumberOfElements = listState.layoutInfo.totalItemsCount launch { snapshotFlow { listState.firstVisibleItemIndex } + .drop(1) .collect { - autoScrollToBottom = it == 0 + if (oldNumberOfElements != listState.layoutInfo.totalItemsCount) { + oldNumberOfElements = listState.layoutInfo.totalItemsCount + return@collect + } + autoScrollToBottom.value = it == 0 } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 30bbe72a72..8b4f7f8ec9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -201,7 +201,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools SectionItemView( click = { withBGApi { - openChat(chatRh, forwardedFromItem.chatInfo, chatModel) + openChat(chatRh, forwardedFromItem.chatInfo) ModalManager.end.closeModals() } }, 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 new file mode 100644 index 0000000000..22fba59004 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -0,0 +1,301 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.snapshots.SnapshotStateList +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.platform.chatModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.min + +const val TRIM_KEEP_COUNT = 200 + +suspend fun apiLoadMessages( + rhId: Long?, + chatType: ChatType, + apiId: Long, + pagination: ChatPagination, + chatState: ActiveChatState, + search: String = "", + visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } +) = coroutineScope { + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, 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 (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState + val oldItems = chatModel.chatItems.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) + } + } + withContext(Dispatchers.Main) { + chatModel.chatItemStatuses.clear() + chatModel.chatItems.replaceAll(chat.chatItems) + chatModel.chatId.value = chat.chatInfo.id + splits.value = newSplits + if (chat.chatItems.isNotEmpty()) { + unreadAfterItemId.value = chat.chatItems.last().id + } + totalAfter.value = navInfo.afterTotal + unreadTotal.value = chat.chatStats.unreadCount + unreadAfter.value = navInfo.afterUnread + unreadAfterNewestLoaded.value = navInfo.afterUnread + } + } + is ChatPagination.Before -> { + newItems.addAll(oldItems) + val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } + if (indexInCurrentItems == -1) return@coroutineScope + val (newIds, _) = mapItemsToIds(chat.chatItems) + val wasSize = newItems.size + val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + ) + val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) + newItems.addAll(insertAt, chat.chatItems) + withContext(Dispatchers.Main) { + chatModel.chatItems.replaceAll(newItems) + splits.value = newSplits + chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) + } + } + is ChatPagination.After -> { + newItems.addAll(oldItems) + val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } + if (indexInCurrentItems == -1) return@coroutineScope + + val mappedItems = mapItemsToIds(chat.chatItems) + val newIds = mappedItems.first + val (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( + mappedItems.second, pagination.chatItemId, newItems, newIds, chat, splits + ) + val indexToAdd = min(indexInCurrentItems + 1, newItems.size) + val indexToAddIsLast = indexToAdd == newItems.size + newItems.addAll(indexToAdd, chat.chatItems) + withContext(Dispatchers.Main) { + chatModel.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 + if (indexToAddIsLast) { + unreadAfterNewestLoaded.value -= unreadInLoaded + } + } + } + is ChatPagination.Around -> { + newItems.addAll(oldItems) + 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) + splits.value = listOf(chat.chatItems.last().id) + newSplits + unreadAfterItemId.value = chat.chatItems.last().id + totalAfter.value = navInfo.afterTotal + unreadTotal.value = chat.chatStats.unreadCount + unreadAfter.value = navInfo.afterUnread + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } + } + is ChatPagination.Last -> { + newItems.addAll(oldItems) + removeDuplicates(newItems, chat) + newItems.addAll(chat.chatItems) + withContext(Dispatchers.Main) { + chatModel.chatItems.replaceAll(newItems) + unreadAfterNewestLoaded.value = 0 + } + } + } +} + +private data class ModifiedSplits ( + val oldUnreadSplitIndex: Int, + val newUnreadSplitIndex: Int, + val trimmedIds: Set, + val newSplits: List, +) + +private fun removeDuplicatesAndModifySplitsOnBeforePagination( + unreadAfterItemId: StateFlow, + newItems: SnapshotStateList, + newIds: Set, + splits: StateFlow>, + visibleItemIndexesNonReversed: () -> IntRange +): ModifiedSplits { + var oldUnreadSplitIndex: Int = -1 + var newUnreadSplitIndex: Int = -1 + val visibleItemIndexes = visibleItemIndexesNonReversed() + var lastSplitIndexTrimmed = -1 + var allowedTrimming = true + var index = 0 + /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ + val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT + val trimmedIds = mutableSetOf() + val prevItemTrimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT + 1 .. newItems.size - TRIM_KEEP_COUNT + var newSplits = splits.value + + newItems.removeAll { + val invisibleItemToTrim = trimRange.contains(index) && allowedTrimming + val prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming + // may disable it after clearing the whole split range + if (splits.value.isNotEmpty() && it.id == splits.value.firstOrNull()) { + // trim only in one split range + allowedTrimming = false + } + val indexInSplits = splits.value.indexOf(it.id) + if (indexInSplits != -1) { + lastSplitIndexTrimmed = indexInSplits + } + if (invisibleItemToTrim) { + if (prevItemWasTrimmed) { + trimmedIds.add(it.id) + } else { + newUnreadSplitIndex = index + // prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead. + // this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction + if (lastSplitIndexTrimmed == -1) { + newSplits = listOf(it.id) + newSplits + } else { + val new = ArrayList(newSplits) + new[lastSplitIndexTrimmed] = it.id + newSplits = new + } + } + } + if (unreadAfterItemId.value == it.id) { + oldUnreadSplitIndex = index + } + index++ + (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains(it.id) + } + // will remove any splits that now becomes obsolete because items were merged + newSplits = newSplits.filterNot { split -> newIds.contains(split) || trimmedIds.contains(split) } + return ModifiedSplits(oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) +} + +private fun removeDuplicatesAndModifySplitsOnAfterPagination( + unreadInLoaded: Int, + paginationChatItemId: Long, + newItems: SnapshotStateList, + newIds: Set, + chat: Chat, + splits: StateFlow> +): Pair, Int> { + var unreadInLoaded = unreadInLoaded + var firstItemIdBelowAllSplits: Long? = null + val splitsToRemove = ArrayList() + val indexInSplitRanges = splits.value.indexOf(paginationChatItemId) + // Currently, it should always load from split range + val loadingFromSplitRange = indexInSplitRanges != -1 + val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList() + newItems.removeAll { + val duplicate = newIds.contains(it.id) + if (loadingFromSplitRange && duplicate) { + if (splitsToMerge.contains(it.id)) { + splitsToMerge.remove(it.id) + splitsToRemove.add(it.id) + } else if (firstItemIdBelowAllSplits == null && splitsToMerge.isEmpty()) { + // we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items + firstItemIdBelowAllSplits = it.id + } + } + if (duplicate && it.isRcvNew) { + unreadInLoaded-- + } + duplicate + } + var newSplits: List = emptyList() + if (firstItemIdBelowAllSplits != null) { + // no splits anymore, all were merged with bottom items + newSplits = emptyList() + } else { + if (splitsToRemove.isNotEmpty()) { + val new = ArrayList(splits.value) + new.removeAll(splitsToRemove.toSet()) + newSplits = new + } + val enlargedSplit = splits.value.indexOf(paginationChatItemId) + if (enlargedSplit != -1) { + // move the split to the end of loaded items + val new = ArrayList(splits.value) + new[enlargedSplit] = chat.chatItems.last().id + newSplits = new + // Log.d(TAG, "Enlarged split range $newSplits") + } + } + return newSplits to unreadInLoaded +} + +private fun removeDuplicatesAndUpperSplits( + newItems: SnapshotStateList, + chat: Chat, + splits: StateFlow>, + visibleItemIndexesNonReversed: () -> IntRange +): List { + if (splits.value.isEmpty()) { + removeDuplicates(newItems, chat) + return splits.value + } + + val newSplits = splits.value.toMutableList() + val visibleItemIndexes = visibleItemIndexesNonReversed() + val (newIds, _) = mapItemsToIds(chat.chatItems) + val idsToTrim = ArrayList>() + idsToTrim.add(mutableSetOf()) + var index = 0 + newItems.removeAll { + val duplicate = newIds.contains(it.id) + if (!duplicate && visibleItemIndexes.first > index) { + idsToTrim.last().add(it.id) + } + if (visibleItemIndexes.first > index && splits.value.contains(it.id)) { + newSplits -= it.id + // closing previous range. All items in idsToTrim that ends with empty set should be deleted. + // Otherwise, the last set should be excluded from trimming because it is in currently visible split range + idsToTrim.add(mutableSetOf()) + } + + index++ + duplicate + } + if (idsToTrim.last().isNotEmpty()) { + // it has some elements to trim from currently visible range which means the items shouldn't be trimmed + // Otherwise, the last set would be empty + idsToTrim.removeLast() + } + val allItemsToDelete = idsToTrim.flatten() + if (allItemsToDelete.isNotEmpty()) { + newItems.removeAll { allItemsToDelete.contains(it.id) } + } + return newSplits +} + +// ids, number of unread items +private fun mapItemsToIds(items: List): Pair, Int> { + var unreadInLoaded = 0 + val ids = mutableSetOf() + var i = 0 + while (i < items.size) { + val item = items[i] + ids.add(item.id) + if (item.isRcvNew) { + unreadInLoaded++ + } + i++ + } + return ids to unreadInLoaded +} + +private fun removeDuplicates(newItems: SnapshotStateList, chat: Chat) { + val (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { newIds.contains(it.id) } +} 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 new file mode 100644 index 0000000000..fda5c35e01 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -0,0 +1,379 @@ +package chat.simplex.common.views.chat + +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 + +data class MergedItems ( + val items: List, + val splits: List, + // chat item id, index in list + val indexInParentItems: Map, +) { + companion object { + fun create(items: List, unreadCount: State, revealedItems: Set, chatState: ActiveChatState): MergedItems { + if (items.isEmpty()) return MergedItems(emptyList(), emptyList(), emptyMap()) + + val unreadAfterItemId = chatState.unreadAfterItemId + val itemSplits = chatState.splits.value + val mergedItems = ArrayList() + // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems + val splitRanges = ArrayList() + val indexInParentItems = mutableMapOf() + var index = 0 + var unclosedSplitIndex: Int? = null + var unclosedSplitIndexInParent: Int? = null + var visibleItemIndexInParent = -1 + var unreadBefore = unreadCount.value - chatState.unreadAfterNewestLoaded.value + var lastRevealedIdsInMergedItems: MutableList? = null + var lastRangeInReversedForMergedItems: MutableStateFlow? = null + var recent: MergedItem? = null + while (index < items.size) { + val item = items[index] + val prev = items.getOrNull(index - 1) + val next = items.getOrNull(index + 1) + val category = item.mergeCategory + val itemIsSplit = itemSplits.contains(item.id) + + if (item.id == unreadAfterItemId.value) { + unreadBefore = unreadCount.value - chatState.unreadAfter.value + } + if (item.isRcvNew) unreadBefore-- + + val revealed = item.mergeCategory == null || revealedItems.contains(item.id) + if (recent is MergedItem.Grouped && recent.mergeCategory == category && !revealedItems.contains(recent.items.first().item.id) && !itemIsSplit) { + val listItem = ListItem(item, prev, next, unreadBefore) + recent.items.add(listItem) + + if (item.isRcvNew) { + recent.unreadIds.add(item.id) + } + if (lastRevealedIdsInMergedItems != null && lastRangeInReversedForMergedItems != null) { + if (revealed) { + lastRevealedIdsInMergedItems += item.id + } + lastRangeInReversedForMergedItems.value = lastRangeInReversedForMergedItems.value.first..index + } + } else { + visibleItemIndexInParent++ + val listItem = ListItem(item, prev, next, unreadBefore) + recent = if (item.mergeCategory != null) { + if (item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == null) { + lastRevealedIdsInMergedItems = if (revealedItems.contains(item.id)) mutableListOf(item.id) else mutableListOf() + } else if (revealed) { + lastRevealedIdsInMergedItems += item.id + } + lastRangeInReversedForMergedItems = MutableStateFlow(index .. index) + MergedItem.Grouped( + items = arrayListOf(listItem), + revealed = revealed, + revealedIdsWithinGroup = lastRevealedIdsInMergedItems, + rangeInReversed = lastRangeInReversedForMergedItems, + mergeCategory = item.mergeCategory, + startIndexInReversedItems = index, + unreadIds = if (item.isRcvNew) mutableSetOf(item.id) else mutableSetOf() + ) + } else { + lastRangeInReversedForMergedItems = null + MergedItem.Single( + item = listItem, + startIndexInReversedItems = index + ) + } + mergedItems.add(recent) + } + if (itemIsSplit) { + // found item that is considered as a split + if (unclosedSplitIndex != null && unclosedSplitIndexInParent != null) { + // it was at least second split in the list + splitRanges.add(SplitRange(unclosedSplitIndex until index, unclosedSplitIndexInParent until visibleItemIndexInParent)) + } + unclosedSplitIndex = index + unclosedSplitIndexInParent = visibleItemIndexInParent + } else if (index + 1 == items.size && unclosedSplitIndex != null && unclosedSplitIndexInParent != null) { + // just one split for the whole list, there will be no more, it's the end + splitRanges.add(SplitRange(unclosedSplitIndex .. index, unclosedSplitIndexInParent .. visibleItemIndexInParent)) + } + indexInParentItems[item.id] = visibleItemIndexInParent + index++ + } + return MergedItems( + mergedItems, + splitRanges, + indexInParentItems + ) + } + } +} + +sealed class MergedItem { + abstract val startIndexInReversedItems: Int + + // the item that is always single, cannot be grouped and always revealed + data class Single( + val item: ListItem, + override val startIndexInReversedItems: Int, + ): MergedItem() + + /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed, + * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance + * of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of + * visible rows in ChatView LazyColumn */ + @Stable + data class Grouped ( + val items: ArrayList, + val revealed: Boolean, + // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action + // it's the same list instance for all Grouped items within revealed group + /** @see reveal */ + val revealedIdsWithinGroup: MutableList, + val rangeInReversed: MutableStateFlow, + val mergeCategory: CIMergeCategory?, + val unreadIds: MutableSet, + override val startIndexInReversedItems: Int, + ): MergedItem() { + fun reveal(reveal: Boolean, revealedItems: MutableState>) { + val newRevealed = revealedItems.value.toMutableSet() + var i = 0 + if (reveal) { + while (i < items.size) { + newRevealed.add(items[i].item.id) + i++ + } + } else { + while (i < revealedIdsWithinGroup.size) { + newRevealed.remove(revealedIdsWithinGroup[i]) + i++ + } + revealedIdsWithinGroup.clear() + } + revealedItems.value = newRevealed + } + } + + fun hasUnread(): Boolean = when (this) { + is Single -> item.item.isRcvNew + is Grouped -> unreadIds.isNotEmpty() + } + + fun newest(): ListItem = when (this) { + is Single -> item + is Grouped -> items.first() + } + + fun oldest(): ListItem = when (this) { + is Single -> item + is Grouped -> items.last() + } + + fun lastIndexInReversed(): Int = when (this) { + is Single -> startIndexInReversedItems + is Grouped -> startIndexInReversedItems + items.lastIndex + } +} + +data class SplitRange( + /** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first]) + * so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance + * (3, 4 indexes of the splitRange with the split itself at index 3) + * */ + val indexRangeInReversed: IntRange, + /** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */ + val indexRangeInParentItems: IntRange +) + +data class ListItem( + val item: ChatItem, + val prevItem: ChatItem?, + val nextItem: ChatItem?, + // how many unread items before (older than) this one (excluding this one) + val unreadBefore: Int +) + +data class ActiveChatState ( + val splits: MutableStateFlow> = MutableStateFlow(emptyList()), + val unreadAfterItemId: MutableStateFlow = MutableStateFlow(-1L), + // total items after unread after item (exclusive) + val totalAfter: MutableStateFlow = MutableStateFlow(0), + val unreadTotal: MutableStateFlow = MutableStateFlow(0), + // exclusive + val unreadAfter: MutableStateFlow = MutableStateFlow(0), + // exclusive + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) +) { + fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { + toItemId ?: return + val currentIndex = nonReversedItems.indexOfFirst { it.id == unreadAfterItemId.value } + val newIndex = nonReversedItems.indexOfFirst { it.id == toItemId } + if (currentIndex == -1 || newIndex == -1) return + unreadAfterItemId.value = toItemId + val unreadDiff = if (newIndex > currentIndex) { + -nonReversedItems.subList(currentIndex + 1, newIndex + 1).count { it.isRcvNew } + } else { + nonReversedItems.subList(newIndex + 1, currentIndex + 1).count { it.isRcvNew } + } + unreadAfter.value += unreadDiff + } + + fun moveUnreadAfterItem(fromIndex: Int, toIndex: Int, nonReversedItems: List) { + if (fromIndex == -1 || toIndex == -1) return + unreadAfterItemId.value = nonReversedItems[toIndex].id + val unreadDiff = if (toIndex > fromIndex) { + -nonReversedItems.subList(fromIndex + 1, toIndex + 1).count { it.isRcvNew } + } else { + nonReversedItems.subList(toIndex + 1, fromIndex + 1).count { it.isRcvNew } + } + unreadAfter.value += unreadDiff + } + + fun clear() { + splits.value = emptyList() + unreadAfterItemId.value = -1L + totalAfter.value = 0 + unreadTotal.value = 0 + unreadAfter.value = 0 + unreadAfterNewestLoaded.value = 0 + } +} + +fun visibleItemIndexesNonReversed(mergedItems: State, 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 + if (range.first < 0 || range.last < 0) return zero + + // visible items mapped to their underlying data structure which is chatModel.chatItems + return range +} + +fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItemsChangesListener { + override fun read(itemIds: Set?, newItems: List) { + val (_, unreadAfterItemId, _, unreadTotal, unreadAfter) = chatState + if (itemIds == null) { + // special case when the whole chat became read + unreadTotal.value = 0 + unreadAfter.value = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.lastIndex + val ids = itemIds.toMutableSet() + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = unreadTotal.value + var newUnreadAfter = unreadAfter.value + while (i >= 0) { + val item = newItems[i] + if (item.id == unreadAfterItemId.value) { + unreadAfterItemIndex = i + } + if (ids.contains(item.id)) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter-- + } + newUnreadTotal-- + ids.remove(item.id) + if (ids.isEmpty()) break + } + i-- + } + unreadTotal.value = newUnreadTotal + unreadAfter.value = newUnreadAfter + } + override fun added(item: Pair, index: Int) { + if (item.second) { + chatState.unreadAfter.value++ + chatState.unreadTotal.value++ + } + } + override fun removed(itemIds: List>, newItems: List) { + val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState + val newSplits = ArrayList() + for (split in splits.value) { + val index = itemIds.indexOfFirst { it.first == split } + // deleted the item that was right before the split between items, find newer item so it will act like the split + if (index != -1) { + val newSplit = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id + // it the whole section is gone and splits overlap, don't add it at all + if (newSplit != null && !newSplits.contains(newSplit)) { + newSplits.add(newSplit) + } + } else { + newSplits.add(split) + } + } + splits.value = newSplits + + val index = itemIds.indexOfFirst { it.first == unreadAfterItemId.value } + // unread after item was removed + if (index != -1) { + var newUnreadAfterItemId = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id + val newUnreadAfterItemWasNull = newUnreadAfterItemId == null + if (newUnreadAfterItemId == null) { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.firstOrNull()?.id + } + if (newUnreadAfterItemId != null) { + unreadAfterItemId.value = newUnreadAfterItemId + totalAfter.value -= itemIds.count { it.second > index } + unreadTotal.value -= itemIds.count { it.second <= index && it.third } + unreadAfter.value -= itemIds.count { it.second > index && it.third } + if (newUnreadAfterItemWasNull) { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if (newItems.firstOrNull()?.isRcvNew == true) { + unreadTotal.value++ + unreadAfter.value-- + } + } + } else { + // all items were deleted, 0 items in chatItems + unreadAfterItemId.value = -1L + totalAfter.value = 0 + unreadTotal.value = 0 + unreadAfter.value = 0 + } + } else { + totalAfter.value -= itemIds.size + } + } + override fun cleared() { chatState.clear() } +} + +/** Helps in debugging */ +//@Composable +//fun BoxScope.ShowChatState() { +// Box(Modifier.align(Alignment.Center).size(200.dp).background(Color.Black)) { +// val s = chatModel.chatState +// Text( +// "itemId ${s.unreadAfterItemId.value} / ${chatModel.chatItems.value.firstOrNull { it.id == s.unreadAfterItemId.value }?.text}, \nunreadAfter ${s.unreadAfter.value}, afterNewest ${s.unreadAfterNewestLoaded.value}", +// color = Color.White +// ) +// } +//} +//// Returns items mapping for easy checking the structure +//fun MergedItems.mappingToString(): String = items.mapIndexed { index, g -> +// when (g) { +// is MergedItem.Single -> +// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " + +// "revealed true, " + +// "mergeCategory null " + +// "\nunreadBefore ${g.item.unreadBefore}" +// +// is MergedItem.Grouped -> +// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " + +// "revealed ${g.revealed}, " + +// "mergeCategory ${g.items[0].item.mergeCategory} " + +// g.items.mapIndexed { i, it -> +// "\nunreadBefore ${it.unreadBefore} ${Triple(index, g.startIndexInReversedItems + i, it.item.id)}" +// } +// } +//}.toString() 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 2c43a81f7d..8dd3e42440 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 @@ -47,9 +47,9 @@ import kotlinx.coroutines.flow.* import kotlinx.datetime.* import java.io.File import java.net.URI -import kotlin.math.abs -import kotlin.math.sign +import kotlin.math.* +@Stable data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?) @Composable @@ -292,14 +292,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - loadPrevMessages = { chatId -> + loadMessages = { chatId, pagination, chatState, visibleItemIndexes -> val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout - val firstId = chatModel.chatItems.value.firstOrNull()?.id - if (c != null && firstId != null) { - withBGApi { - apiLoadPrevMessages(c, chatModel, firstId, searchText.value) - } + if (c != null) { + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, chatState, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> @@ -376,7 +373,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - }, openDirectChat = { contactId -> scope.launch { - openDirectChat(chatRh, contactId, chatModel) + openDirectChat(chatRh, contactId) } }, forwardItem = { cInfo, cItem -> @@ -516,7 +513,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout if (chatModel.chatId.value != chatInfo.id) return@ChatLayout withBGApi { - apiFindMessages(c, chatModel, value) + apiFindMessages(c, value) searchText.value = value } }, @@ -535,7 +532,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() } } is ChatInfo.InvalidJSON -> { @@ -546,7 +543,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() } } else -> {} @@ -583,7 +580,7 @@ fun ChatLayout( back: () -> Unit, info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadPrevMessages: (ChatId) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -649,7 +646,7 @@ fun ChatLayout( Box(Modifier.fillMaxSize()) { ChatItemsList( remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, + useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -922,7 +919,7 @@ fun BoxScope.ChatItemsList( linkMode: SimplexLinkMode, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadPrevMessages: (ChatId) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -945,65 +942,73 @@ fun BoxScope.ChatItemsList( developerTools: Boolean, showViaProxy: Boolean ) { - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - ScrollToBottom(chatInfo.id, listState, chatModel.chatItems) - var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } - // Scroll to bottom when search value changes from something to nothing and back - LaunchedEffect(searchValue.value.isEmpty()) { - // They are equal when orientation was changed, don't need to scroll. - // LaunchedEffect unaware of this event since it uses remember, not rememberSaveable - if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect - prevSearchEmptiness = searchValue.value.isEmpty() - - if (listState.firstVisibleItemIndex != 0) { - scope.launch { listState.scrollToItem(0) } + 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().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 + * */ + val maxHeightForList = rememberUpdatedState( + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * 2).roundToPx() } + ) + val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index <= 0) { + LazyListState(0, 0) + } else { + LazyListState(index + 1, -maxHeightForList.value) + } + }) + val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } + val loadingMoreItems = remember { mutableStateOf(false) } + val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + if (!loadingMoreItems.value) { + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), 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) + } + } finally { + loadingMoreItems.value = false + } + true } } - PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages) + val chatInfoUpdated = rememberUpdatedState(chatInfo) + val scope = rememberCoroutineScope() + val scrollToItem: (Long) -> Unit = remember { scrollToItem(loadingMoreItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } + + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) + SmallScrollOnNewMessage(listState, chatModel.chatItems) + val finishedInitialComposition = remember { mutableStateOf(false) } + NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - Spacer(Modifier.size(8.dp)) - val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() }) - val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } - val scrollToItem: State<(Long) -> Unit> = remember { - mutableStateOf( - { itemId: Long -> - val index = reversedChatItems.value.indexOfFirst { it.id == itemId } - if (index != -1) { - scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) } - } - } - ) - } - // TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once - LaunchedEffect(chatInfo.id) { - var stopListening = false - snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex } - .distinctUntilChanged() - .filter { !stopListening } - .collect { - onComposed(chatInfo.id) - stopListening = true - } - } DisposableEffectOnGone( + always = { + chatModel.chatItemsChangesListener = recalculateChatStatePositions(chatModel.chatState) + }, whenGone = { VideoPlayerHolder.releaseAll() + chatModel.chatItemsChangesListener = null } ) - LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), - state = listState, - reverseLayout = true, - contentPadding = PaddingValues( - top = topPaddingToContent(), - bottom = composeViewHeight.value - ), - additionalBarOffset = composeViewHeight - ) { - itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem -> + + @Composable + fun ChatViewListItem( + itemAtZeroIndexInWholeList: Boolean, + range: State, + showAvatar: Boolean, + cItem: ChatItem, + itemSeparation: ItemSeparation, + previousItemSeparationLargeGap: Boolean, + revealed: State, + reveal: (Boolean) -> Unit + ) { val itemScope = rememberCoroutineScope() CompositionLocalProvider( // Makes horizontal and vertical scrolling to coexist nicely. @@ -1011,29 +1016,27 @@ fun BoxScope.ChatItemsList( LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { val provider = { - providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed -> + providerForGallery(chatModel.chatItems.value, cItem.id) { indexInReversed -> itemScope.launch { - listState.scrollToItem( - kotlin.math.min(reversedChatItems.value.lastIndex, indexInReversed + 1), + listState.value.scrollToItem( + min(reversedChatItems.value.lastIndex, indexInReversed + 1), -maxHeight.value ) } } } - val revealed = remember { mutableStateOf(false) } - @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, 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.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, 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, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @Composable - fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) { + fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { val dismissState = rememberDismissState(initialValue = DismissValue.Default) { if (it == DismissValue.DismissedToStart) { itemScope.launch { @@ -1060,12 +1063,12 @@ fun BoxScope.ChatItemsList( Box( modifier = modifier.padding( bottom = if (itemSeparation.largeGap) { - if (i == 0) { + if (itemAtZeroIndexInWholeList) { 8.dp } else { 4.dp } - } else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp + } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp ), contentAlignment = Alignment.CenterStart ) { @@ -1089,14 +1092,7 @@ fun BoxScope.ChatItemsList( val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } if (chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { - val member = cItem.chatDir.groupMember - val (prevMember, memCount) = - if (range != null) { - chatModel.getPrevHiddenMember(member, range) - } else { - null to 1 - } - if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { + if (showAvatar) { Column( Modifier .padding(top = 8.dp) @@ -1107,8 +1103,16 @@ fun BoxScope.ChatItemsList( horizontalAlignment = Alignment.Start ) { @Composable - fun MemberNameAndRole() { + fun MemberNameAndRole(range: State) { Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + val member = cItem.chatDir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = + if (rangeValue != null) { + chatModel.getPrevHiddenMember(member, rangeValue) + } else { + null to 1 + } Text( memberNames(member, prevMember, memCount), Modifier @@ -1143,6 +1147,7 @@ fun BoxScope.ChatItemsList( SelectedChatItem(Modifier, cItem.id, selectedChatItems) } Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + val member = cItem.chatDir.groupMember Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { MemberImage(member) } @@ -1154,7 +1159,7 @@ fun BoxScope.ChatItemsList( } if (cItem.content.showMemberName) { DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { - MemberNameAndRole() + MemberNameAndRole(range) Item() } } else { @@ -1217,58 +1222,68 @@ fun BoxScope.ChatItemsList( } } } - - val (currIndex, nextItem) = chatModel.getNextChatItem(cItem) - val ciCategory = cItem.mergeCategory - if (ciCategory != null && ciCategory == nextItem?.mergeCategory) { - // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView - } else { - val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory) - - val itemSeparation = getItemSeparation(cItem, nextItem) - val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null - - if (itemSeparation.date != null) { - DateSeparator(itemSeparation.date) - } - - val range = chatViewItemsRange(currIndex, prevHidden) - val reversed = reversedChatItems.value - if (revealed.value && range != null) { - reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci -> - val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1] - ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation) - } - } else { - ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation) - } - - if (i == reversed.lastIndex) { - DateSeparator(cItem.meta.itemTs) - } + if (itemSeparation.date != null) { + DateSeparator(itemSeparation.date) } + ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) + } + } + LazyColumnWithScrollBar( + Modifier.align(Alignment.BottomCenter), + state = listState.value, + reverseLayout = true, + contentPadding = PaddingValues( + top = topPaddingToContent(), + bottom = composeViewHeight.value + ), + additionalBarOffset = composeViewHeight + ) { + val mergedItemsValue = mergedItems.value + itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> + val isLastItem = index == mergedItemsValue.items.lastIndex + val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null + val listItem = merged.newest() + val item = listItem.item + val range = if (merged is MergedItem.Grouped) { + merged.rangeInReversed.value + } else { + null + } + val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true + val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } + val itemSeparation: ItemSeparation + val prevItemSeparationLargeGap: Boolean + if (merged is MergedItem.Single || isRevealed.value) { + val prev = listItem.prevItem + itemSeparation = getItemSeparation(item, prev) + val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem + prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) + } else { + itemSeparation = getItemSeparation(item, null) + prevItemSeparationLargeGap = false + } + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } - - if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) { - LaunchedEffect(cItem.id) { - itemScope.launch { - delay(600) - markRead(CC.ItemRange(cItem.id, cItem.id), null) - } - } + if (last != null) { + // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items + DateSeparator(last.meta.itemTs) + } + if (item.isRcvNew) { + val (itemIdStart, itemIdEnd) = when (merged) { + is MergedItem.Single -> merged.item.item.id to merged.item.item.id + is MergedItem.Grouped -> merged.items.last().item.id to merged.items.first().item.id } + MarkItemsReadAfterDelay(keyForItem(item), itemIdStart, itemIdEnd, finishedInitialComposition, chatInfo.id, listState, markRead) } } } - FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState) - - FloatingDate( - Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter), - listState, - ) + FloatingButtons(loadingMoreItems, mergedItems, unreadCount, maxHeight, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter), mergedItems, listState) LaunchedEffect(Unit) { - snapshotFlow { listState.isScrollInProgress } + snapshotFlow { listState.value.isScrollInProgress } .collect { chatViewScrollState.value = it } @@ -1276,33 +1291,43 @@ fun BoxScope.ChatItemsList( } @Composable -private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State>) { - val scope = rememberCoroutineScope() - // Helps to scroll to bottom after moving from Group to Direct chat - // and prevents scrolling to bottom on orientation change - var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) } - LaunchedEffect(chatId, shouldAutoScroll) { - if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) { - scope.launch { listState.scrollToItem(0) } +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { + LaunchedEffect(remoteHostId, chatInfo.id) { + try { + loadingMoreItems.value = true + if (chatModel.chatState.totalAfter.value <= 0) return@LaunchedEffect + delay(500) + withContext(Dispatchers.Default) { + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState) + } + } finally { + loadingMoreItems.value = false } - // Don't autoscroll next time until it will be needed - shouldAutoScroll = false to chatId } +} + +@Composable +private fun SmallScrollOnNewMessage(listState: State, chatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } - /* - * Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves. - * When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise - * */ LaunchedEffect(Unit) { - snapshotFlow { chatItems.value.lastOrNull()?.id } + var lastTotalItems = listState.value.layoutInfo.totalItemsCount + var lastItemId = chatItems.value.lastOrNull()?.id + snapshotFlow { listState.value.layoutInfo.totalItemsCount } .distinctUntilChanged() - .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } + .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) { + return@collect + } try { - if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) { - if (appPlatform.isAndroid) listState.animateScrollToItem(0) else listState.scrollToItem(0) + if (listState.value.firstVisibleItemIndex == 0 || listState.value.firstVisibleItemIndex == 1) { + if (appPlatform.isAndroid) listState.value.animateScrollToItem(0) else listState.value.scrollToItem(0) } else { - if (appPlatform.isAndroid) listState.animateScrollBy(scrollDistance) else listState.scrollBy(scrollDistance) + if (appPlatform.isAndroid) listState.value.animateScrollBy(scrollDistance) else listState.value.scrollBy(scrollDistance) } } catch (e: CancellationException) { /** @@ -1317,55 +1342,89 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: } } +@Composable +private fun NotifyChatListOnFinishingComposition( + finishedInitialComposition: MutableState, + chatInfo: ChatInfo, + revealedItems: MutableState>, + listState: State, + onComposed: suspend (chatId: String) -> Unit +) { + LaunchedEffect(chatInfo.id) { + revealedItems.value = emptySet() + snapshotFlow { listState.value.layoutInfo.visibleItemsInfo.lastIndex } + .distinctUntilChanged() + .collect { + onComposed(chatInfo.id) + finishedInitialComposition.value = true + cancel() + } + } +} + @Composable fun BoxScope.FloatingButtons( - chatItems: State>, + loadingMoreItems: MutableState, + mergedItems: State, unreadCount: State, + maxHeight: State, composeViewHeight: State, remoteHostId: Long?, chatInfo: ChatInfo, searchValue: State, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, - listState: LazyListState + listState: State ) { val scope = rememberCoroutineScope() - val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } } + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 - val items = chatItems.value - val from = items.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex - if (items.size <= from || from < 0) return@derivedStateOf 0 - - items.subList(from, items.size).count { it.isRcvNew } + val lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx, mergedItems, listState) ?: return@derivedStateOf -1 + unreadCount.value - lastVisibleItem.unreadBefore } } - val showBottomButtonWithCounter = remember { derivedStateOf { bottomUnreadCount.value > 0 && listState.firstVisibleItemIndex != 0 && searchValue.value.isEmpty() } } - val showBottomButtonWithArrow = remember { derivedStateOf { !showBottomButtonWithCounter.value && listState.firstVisibleItemIndex != 0 } } + val allowToShowBottomWithCounter = remember { mutableStateOf(true) } + val showBottomButtonWithCounter = remember { derivedStateOf { + val allow = allowToShowBottomWithCounter.value + val shouldShow = bottomUnreadCount.value > 0 && listState.value.firstVisibleItemIndex != 0 && searchValue.value.isEmpty() + // this tricky idea is to prevent showing button with arrow in the next frame after creating/receiving new message because the list will + // scroll to that message but before this happens, that button will show up and then will hide itself after scroll finishes. + // This workaround prevents it + allowToShowBottomWithCounter.value = shouldShow + shouldShow && allow + } } + val allowToShowBottomWithArrow = remember { mutableStateOf(true) } + val showBottomButtonWithArrow = remember { derivedStateOf { + val allow = allowToShowBottomWithArrow.value + val shouldShow = !showBottomButtonWithCounter.value && listState.value.firstVisibleItemIndex != 0 + allowToShowBottomWithArrow.value = shouldShow + shouldShow && allow + } } BottomEndFloatingButton( bottomUnreadCount, showBottomButtonWithCounter, showBottomButtonWithArrow, composeViewHeight, - onClickArrowDown = { - scope.launch { listState.animateScrollToItem(0) } - }, - onClickCounter = { - val firstVisibleOffset = (-maxHeight.value * 0.8).toInt() - scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) } - } + onClick = { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } } } ) // Don't show top FAB if is in search if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp - val topUnreadCount = remember { derivedStateOf { unreadCount.value - bottomUnreadCount.value } } + val topUnreadCount = remember { derivedStateOf { if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } } val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent()).align(Alignment.TopEnd), topUnreadCount, - onClick = { scope.launch { listState.animateScrollBy(maxHeight.value.toFloat()) } }, + onClick = { + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index != -1) { + // scroll to the top unread item + scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } } + } + }, onLongClick = { showDropDown.value = true } ) @@ -1381,10 +1440,11 @@ fun BoxScope.FloatingButtons( generalGetString(MR.strings.mark_read), painterResource(MR.images.ic_check), onClick = { - val minUnreadItemId = chatModel.chats.value.firstOrNull { it.remoteHostId == remoteHostId && it.id == chatInfo.id }?.chatStats?.minUnreadItemId ?: return@ItemAction + val chat = chatModel.chats.value.firstOrNull { it.remoteHostId == remoteHostId && it.id == chatInfo.id } ?: return@ItemAction + val minUnreadItemId = chat.chatStats.minUnreadItemId markRead( - CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), - bottomUnreadCount.value + CC.ItemRange(minUnreadItemId, chat.chatItems.lastOrNull()?.id ?: return@ItemAction), + 0 ) showDropDown.value = false }) @@ -1395,46 +1455,106 @@ fun BoxScope.FloatingButtons( @Composable fun PreloadItems( chatId: String, - listState: LazyListState, - remaining: Int = 10, - onLoadMore: (ChatId) -> Unit, + ignoreLoadingRequests: MutableSet, + mergedItems: State, + listState: State, + remaining: Int, + loadItems: suspend (ChatId, ChatPagination) -> Boolean, ) { // Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection val allowLoad = remember { mutableStateOf(false) } val chatId = rememberUpdatedState(chatId) - val onLoadMore = rememberUpdatedState(onLoadMore) - LaunchedEffect(Unit) { - snapshotFlow { chatId.value } - .filterNotNull() - .collect { - allowLoad.value = listState.layoutInfo.totalItemsCount == listState.layoutInfo.visibleItemsInfo.size - delay(500) - allowLoad.value = true + val loadItems = rememberUpdatedState(loadItems) + val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, mergedItems, listState, remaining, loadItems) +} + +@Composable +private fun PreloadItemsBefore( + allowLoad: State, + chatId: State, + ignoreLoadingRequests: State>, + mergedItems: State, + listState: State, + remaining: Int, + loadItems: State Boolean>, +) { + KeyChangeEffect(allowLoad.value, chatId.value) { + snapshotFlow { listState.value.firstVisibleItemIndex } + .distinctUntilChanged() + .map { firstVisibleIndex -> + 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 + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining && items.size >= ChatPagination.INITIAL_COUNT) { + lastIndexToLoadFrom = items.lastIndex + } + if (allowLoad.value && lastIndexToLoadFrom != null) { + items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id + } else { + null + } } - } - KeyChangeEffect(allowLoad.value) { - snapshotFlow { - val lInfo = listState.layoutInfo - val totalItemsNumber = lInfo.totalItemsCount - val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) - totalItemsNumber + ChatPagination.PRELOAD_COUNT - else - 0 - } - .filter { it > 0 } - .collect { - onLoadMore.value(chatId.value) + .filterNotNull() + .filter { !ignoreLoadingRequests.value.contains(it) } + .collect { loadFromItemId -> + withBGApi { + val sizeWas = chatModel.chatItems.value.size + val firstItemIdWas = chatModel.chatItems.value.firstOrNull()?.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) { + ignoreLoadingRequests.value.add(loadFromItemId) + } + } } } } -private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean = - when (val dir = prevItem?.chatDir) { - is CIDirection.GroupSnd -> true - is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId - else -> false +@Composable +private fun PreloadItemsAfter( + allowLoad: MutableState, + chatId: State, + mergedItems: State, + listState: State, + remaining: Int, + loadItems: State Boolean>, +) { + LaunchedEffect(Unit) { + snapshotFlow { chatId.value } + .distinctUntilChanged() + .filterNotNull() + .collect { + allowLoad.value = listState.value.layoutInfo.totalItemsCount == listState.value.layoutInfo.visibleItemsInfo.size + delay(500) + allowLoad.value = true + } } + LaunchedEffect(chatId.value) { + launch { + snapshotFlow { listState.value.firstVisibleItemIndex } + .distinctUntilChanged() + .map { firstVisibleIndex -> + val items = chatModel.chatItems.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 + } else { + null + } + } + .filterNotNull() + .collect { loadFromItemId -> + withBGApi { + loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) + } + } + } + } +} val MEMBER_IMAGE_SIZE: Dp = 37.dp @@ -1449,8 +1569,8 @@ private fun TopEndFloatingButton( unreadCount: State, onClick: () -> Unit, onLongClick: () -> Unit -) = when { - unreadCount.value > 0 -> { +) { + if (unreadCount.value > 0) { val interactionSource = interactionSourceWithDetection(onClick, onLongClick) FloatingActionButton( {}, // no action here @@ -1466,8 +1586,6 @@ private fun TopEndFloatingButton( ) } } - else -> { - } } @Composable @@ -1483,52 +1601,43 @@ fun topPaddingToContent(): Dp { @Composable private fun FloatingDate( modifier: Modifier, - listState: LazyListState, + mergedItems: State, + listState: State, ) { - var nearBottomIndex by remember { mutableStateOf(-1) } - var isNearBottom by remember { mutableStateOf(true) } + 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().roundToPx() }) + val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { - if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0) { - val lastFullyVisibleOffset = listState.layoutInfo.viewportEndOffset - val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - (listState.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset + item.size <= lastFullyVisibleOffset && item.size > 0 }?.index ?: 0) - val item = chatModel.chatItems.value.getOrNull(lastVisibleChatItemIndex) + if (listState.value.layoutInfo.visibleItemsInfo.lastIndex >= 0) { + val lastVisibleChatItem = lastFullyVisibleIemInListState(topPaddingToContentPx, density, fontSizeSqrtMultiplier, mergedItems, listState) val timeZone = TimeZone.currentSystemDefault() - item?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone) + lastVisibleChatItem?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone) } else { null } } } - val showDate = remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - launch { - snapshotFlow { chatModel.chatId.value } - .distinctUntilChanged() - .collect { - showDate.value = false - isNearBottom = true - nearBottomIndex = -1 - } - } - } LaunchedEffect(Unit) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo } + snapshotFlow { listState.value.layoutInfo.visibleItemsInfo } .collect { visibleItemsInfo -> if (visibleItemsInfo.find { it.index == 0 } != null) { var elapsedOffset = 0 for (it in visibleItemsInfo) { - if (elapsedOffset >= listState.layoutInfo.viewportSize.height / 2.5) { - nearBottomIndex = it.index + if (elapsedOffset >= listState.value.layoutInfo.viewportSize.height / 2.5) { + nearBottomIndex.value = it.index break; } elapsedOffset += it.size } } - isNearBottom = if (nearBottomIndex == -1) true else (visibleItemsInfo.firstOrNull()?.index ?: 0) <= nearBottomIndex + isNearBottom.value = if (nearBottomIndex.value == -1) true else (visibleItemsInfo.firstOrNull()?.index ?: 0) <= nearBottomIndex.value } } @@ -1536,7 +1645,7 @@ private fun FloatingDate( if (isVisible) { val now = Clock.System.now() val date = lastVisibleItemDate.value - if (!isNearBottom && !showDate.value && date != null && getTimestampDateText(date) != getTimestampDateText(now)) { + if (!isNearBottom.value && !showDate.value && date != null && getTimestampDateText(date) != getTimestampDateText(now)) { showDate.value = true } } else if (showDate.value) { @@ -1546,7 +1655,7 @@ private fun FloatingDate( LaunchedEffect(Unit) { var hideDateWhenNotScrolling: Job = Job() - snapshotFlow { listState.firstVisibleItemScrollOffset } + snapshotFlow { listState.value.firstVisibleItemScrollOffset } .collect { setDateVisibility(true) hideDateWhenNotScrolling.cancel() @@ -1654,6 +1763,93 @@ private fun DateSeparator(date: Instant) { ) } +@Composable +private fun MarkItemsReadAfterDelay( + itemKey: String, + itemIdStart: Long, + itemIdEnd: Long, + finishedInitialComposition: State, + chatId: ChatId, + listState: State, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit +) { + // items can be "visible" in terms of LazyColumn but hidden behind compose view/appBar. So don't count such item as visible and not mark read + val itemIsPartiallyAboveCompose = remember { derivedStateOf { + val item = listState.value.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey } + if (item != null) { + item.offset >= 0 || -item.offset < item.size + } else { + false + } + } } + LaunchedEffect(itemIsPartiallyAboveCompose.value, itemIdStart, itemIdEnd, finishedInitialComposition.value, chatId) { + if (chatId != ChatModel.chatId.value || !itemIsPartiallyAboveCompose.value || !finishedInitialComposition.value) return@LaunchedEffect + + delay(600L) + markRead(CC.ItemRange(itemIdStart, itemIdEnd), null) + } +} + +private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { + val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value + return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + item.offset <= lastFullyVisibleOffset + }?.index ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() +} + +private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State, listState: State): ChatItem? { + val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier + return mergedItems.value.items.getOrNull( + (listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 + } + ?.index + ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) + ?: -1)?.newest()?.item +} + +private fun scrollToItem( + loadingMoreItems: MutableState, + chatInfo: State, + maxHeight: State, + scope: CoroutineScope, + reversedChatItems: State>, + mergedItems: State, + listState: State, + loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, +): (Long) -> Unit = { itemId: Long -> + withApi { + try { + var index = mergedItems.value.indexInParentItems[itemId] ?: -1 + // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to + // it will trigger loading more items and will scroll to incorrect position (because of trimming) + loadingMoreItems.value = true + if (index == -1) { + 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) + } + } + var repeatsLeft = 50 + while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { + delay(20) + repeatsLeft-- + } + index = mergedItems.value.indexInParentItems[itemId] ?: -1 + } + if (index != -1) { + withContext(scope.coroutineContext) { + listState.value.animateScrollToItem(min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) + } + } + } finally { + loadingMoreItems.value = false + } + } +} + val chatViewScrollState = MutableStateFlow(false) fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) { @@ -1684,12 +1880,11 @@ private fun BoxScope.BottomEndFloatingButton( showButtonWithCounter: State, showButtonWithArrow: State, composeViewHeight: State, - onClickArrowDown: () -> Unit, - onClickCounter: () -> Unit + onClick: () -> Unit ) = when { showButtonWithCounter.value -> { FloatingActionButton( - onClick = onClickCounter, + onClick = onClick, elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), backgroundColor = MaterialTheme.colors.secondaryVariant, @@ -1703,7 +1898,7 @@ private fun BoxScope.BottomEndFloatingButton( } showButtonWithArrow.value -> { FloatingActionButton( - onClick = onClickArrowDown, + onClick = onClick, elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), backgroundColor = MaterialTheme.colors.secondaryVariant, @@ -1861,6 +2056,26 @@ fun Modifier.chatViewBackgroundModifier( ) } +private fun findLastIndexToLoadFromInSplits(firstVisibleIndex: Int, lastVisibleIndex: Int, remaining: Int, splits: List): Int? { + for (split in splits) { + // before any split + if (split.indexRangeInParentItems.first > firstVisibleIndex) { + if (lastVisibleIndex > (split.indexRangeInParentItems.first - remaining)) { + return split.indexRangeInReversed.first - 1 + } + break + } + val containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex) + if (containsInRange) { + if (lastVisibleIndex > (split.indexRangeInParentItems.last - remaining)) { + return split.indexRangeInReversed.last + } + break + } + } + return null +} + fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? = if (currIndex != null && prevHidden != null && prevHidden > currIndex) { currIndex..prevHidden @@ -1868,6 +2083,16 @@ fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? = null } +private suspend fun tryBlockAndSetLoadingMore(loadingMoreItems: MutableState, block: suspend () -> Unit) { + try { + loadingMoreItems.value = true + block() + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } finally { + loadingMoreItems.value = false + } +} sealed class ProviderMedia { data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() @@ -1875,7 +2100,6 @@ sealed class ProviderMedia { } fun providerForGallery( - listStateIndex: Int, chatItems: List, cItemId: Long, scrollTo: (Int) -> Unit @@ -1943,15 +2167,18 @@ fun providerForGallery( override fun onDismiss(index: Int) { val internalIndex = initialIndex - index - val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return + val item = item(internalIndex, initialChatId) + val indexInChatItems = item?.first ?: return val indexInReversed = chatItems.lastIndex - indexInChatItems // Do not scroll to active item, just to different items - if (indexInReversed == listStateIndex) return + if (item.second.id == cItemId) return scrollTo(indexInReversed) } } } +private fun keyForItem(item: ChatItem): String = (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() + private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { override val longPressTimeoutMillis get() = @@ -2042,23 +2269,38 @@ private fun handleForwardConfirmation( ) } -private fun getItemSeparation(chatItem: ChatItem, nextItem: ChatItem?): ItemSeparation { - if (nextItem == null) { +private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSeparation { + if (prevItem == null) { return ItemSeparation(timestamp = true, largeGap = true, date = null) } + val sameMemberAndDirection = if (prevItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { + chatItem.chatDir.groupMember.groupMemberId == prevItem.chatDir.groupMember.groupMemberId + } else chatItem.chatDir.sent == prevItem.chatDir.sent + val largeGap = !sameMemberAndDirection || (abs(prevItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) + + return ItemSeparation( + timestamp = largeGap || prevItem.meta.timestampText != chatItem.meta.timestampText, + largeGap = largeGap, + date = if (getTimestampDateText(chatItem.meta.itemTs) == getTimestampDateText(prevItem.meta.itemTs)) null else prevItem.meta.itemTs + ) +} + +private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { + if (nextItem == null) { + return true + } + val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId } else chatItem.chatDir.sent == nextItem.chatDir.sent - val largeGap = !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) - - return ItemSeparation( - timestamp = largeGap || nextItem.meta.timestampText != chatItem.meta.timestampText, - largeGap = largeGap, - date = if (getTimestampDateText(chatItem.meta.itemTs) == getTimestampDateText(nextItem.meta.itemTs)) null else nextItem.meta.itemTs - ) + return !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) } +private fun shouldShowAvatar(current: ChatItem, older: ChatItem?) = + current.chatDir is CIDirection.GroupRcv && (older == null || (older.chatDir !is CIDirection.GroupRcv || older.chatDir.groupMember.memberId != current.chatDir.groupMember.memberId)) + + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, @@ -2102,7 +2344,7 @@ fun PreviewChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadPrevMessages = {}, + loadMessages = { _, _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2174,7 +2416,7 @@ fun PreviewGroupChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadPrevMessages = {}, + 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 fd1d3ab92d..6cd46d49a6 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 @@ -429,8 +429,8 @@ fun ComposeView( ttl = ttl ) - chatItems?.forEach { chatItem -> - withChats { + withChats { + chatItems?.forEach { chatItem -> addChatItem(rhId, chat.chatInfo, chatItem) } } 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 a03cff2bb0..a78dd36887 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 @@ -70,17 +70,9 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) - if (c != null) { - withChats { - if (chatModel.getContactChat(it) == null) { - addChat(c) - } - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(c.chatItems) - chatModel.chatId.value = c.id - closeAll() - } + apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) + if (chatModel.getContactChat(it) != null) { + closeAll() } } }, @@ -92,7 +84,7 @@ fun GroupMemberInfoView( val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) withChats { addChat(memberChat) - openLoadedChat(memberChat, chatModel) + openLoadedChat(memberChat) } closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) 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 19cc949543..9bb3cef1d7 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 @@ -21,7 +21,7 @@ fun CIChatFeatureView( feature: Feature, iconColor: Color, icon: Painter? = null, - revealed: MutableState, + revealed: State, showMenu: MutableState, ) { val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index ca93349092..9f7b5dc9c6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -431,6 +431,9 @@ fun VideoPreviewImageViewFullScreen(preview: ImageBitmap, onClick: () -> Unit, o @Composable expect fun LocalWindowWidth(): Dp +@Composable +expect fun LocalWindowHeight(): Dp + @Composable private fun progressIndicator() { CircularProgressIndicator( 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 3db9b55c5b..5096992c29 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 @@ -56,8 +56,8 @@ fun ChatItemView( imageProvider: (() -> ImageGalleryProvider)? = null, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - revealed: MutableState, - range: IntRange?, + revealed: State, + range: State, selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, @@ -79,6 +79,7 @@ fun ChatItemView( findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, + reveal: (Boolean) -> Unit, developerTools: Boolean, showViaProxy: Boolean, showTimestamp: Boolean, @@ -91,7 +92,7 @@ fun ChatItemView( val showMenu = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } - val live = composeState.value.liveMessage != null + val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, @@ -275,7 +276,7 @@ fun ChatItemView( } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) if (revealed.value) { - HideItemAction(revealed, showMenu) + HideItemAction(revealed, showMenu, reveal) } if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) @@ -296,11 +297,11 @@ fun ChatItemView( cItem.meta.itemDeleted != null -> { DefaultDropdownMenu(showMenu) { if (revealed.value) { - HideItemAction(revealed, showMenu) + HideItemAction(revealed, showMenu, reveal) } else if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu) - } else if (range != null) { - ExpandItemAction(revealed, showMenu) + RevealItemAction(revealed, showMenu, reveal) + } else if (range.value != null) { + ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -320,12 +321,12 @@ fun ChatItemView( } } } - cItem.mergeCategory != null && ((range?.count() ?: 0) > 1 || revealed.value) -> { + cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { DefaultDropdownMenu(showMenu) { if (revealed.value) { - ShrinkItemAction(revealed, showMenu) + ShrinkItemAction(revealed, showMenu, reveal) } else { - ExpandItemAction(revealed, showMenu) + ExpandItemAction(revealed, showMenu, reveal) } DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { @@ -350,7 +351,7 @@ fun ChatItemView( fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu) + RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -623,7 +624,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( cItem: ChatItem, - revealed: MutableState, + revealed: State, showMenu: MutableState, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -700,48 +701,48 @@ fun SelectItemAction( } @Composable -private fun RevealItemAction(revealed: MutableState, showMenu: MutableState) { +private fun RevealItemAction(revealed: State, showMenu: MutableState, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.reveal_verb), painterResource(MR.images.ic_visibility), onClick = { - revealed.value = true + reveal(true) showMenu.value = false } ) } @Composable -private fun HideItemAction(revealed: MutableState, showMenu: MutableState) { +private fun HideItemAction(revealed: State, showMenu: MutableState, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.hide_verb), painterResource(MR.images.ic_visibility_off), onClick = { - revealed.value = false + reveal(false) showMenu.value = false } ) } @Composable -private fun ExpandItemAction(revealed: MutableState, showMenu: MutableState) { +private fun ExpandItemAction(revealed: State, showMenu: MutableState, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.expand_verb), painterResource(MR.images.ic_expand_all), onClick = { - revealed.value = true + reveal(true) showMenu.value = false }, ) } @Composable -private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableState) { +private fun ShrinkItemAction(revealed: State, showMenu: MutableState, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.hide_verb), painterResource(MR.images.ic_collapse_all), onClick = { - revealed.value = false + reveal(false) showMenu.value = false }, ) @@ -1063,7 +1064,7 @@ fun PreviewChatItemView( linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, - range = 0..1, + range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, deleteMessage = { _, _ -> }, @@ -1084,6 +1085,7 @@ fun PreviewChatItemView( findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, + reveal = {}, developerTools = false, showViaProxy = false, showTimestamp = true, @@ -1104,7 +1106,7 @@ fun PreviewChatItemViewDeletedContent() { linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, - range = 0..1, + range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, deleteMessage = { _, _ -> }, @@ -1125,6 +1127,7 @@ fun PreviewChatItemViewDeletedContent() { findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, + reveal = {}, developerTools = false, showViaProxy = false, preview = true, 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 f0480a5c50..cfaa3eced5 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 @@ -129,7 +129,14 @@ fun FramedItemView( .fillMaxWidth() .combinedClickable( onLongClick = { showMenu.value = true }, - onClick = { scrollToItem(qi.itemId?: return@combinedClickable) } + onClick = { + val itemId = qi.itemId + if (itemId != null) { + scrollToItem(itemId) + } else { + showQuotedItemDoesNotExistAlert() + } + } ) .onRightClick { showMenu.value = true } ) { @@ -465,6 +472,13 @@ fun CenteredRowLayout( } } +fun showQuotedItemDoesNotExistAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_deleted_or_not_received_error_title), + text = generalGetString(MR.strings.message_deleted_or_not_received_error_desc) + ) +} + /* class EditedProvider: PreviewParameterProvider { 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 ea71895ce5..d2e19a37d6 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 @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -41,7 +41,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState) { +private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State) { var i = getChatItemIndexOrNull(chatItem) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { 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 3c7f1e781f..d071a9d4fd 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 @@ -1,7 +1,6 @@ package chat.simplex.common.views.chatlist import SectionItemView -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -14,6 +13,7 @@ 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.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign @@ -33,6 +33,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock +import kotlin.math.min @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { @@ -71,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false) } }, - click = { scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } }, + click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) @@ -90,7 +91,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout) } }, - click = { if (!inProgress.value) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } }, + click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) @@ -108,7 +109,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false) } }, - click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, + click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { NoteFolderMenuItems(chat, showMenu, showMarkRead) @@ -187,7 +188,7 @@ fun ErrorChatListItem() { suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) - else -> openChat(rhId, ChatInfo.Direct(contact), chatModel) + else -> openChat(rhId, ChatInfo.Direct(contact)) } } @@ -195,54 +196,31 @@ suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) - else -> openChat(rhId, ChatInfo.Group(groupInfo), chatModel) + else -> openChat(rhId, ChatInfo.Group(groupInfo)) } } -suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) { - openChat(rhId, ChatInfo.Local(noteFolder), chatModel) -} +suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(rhId, ChatInfo.Local(noteFolder)) -suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope { - val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId) - if (chat != null && isActive) { - openLoadedChat(chat, chatModel) - } -} +suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope { - val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId) - if (chat != null && isActive) { - openLoadedChat(chat, chatModel) - } -} +suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope { - val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) - if (chat != null && isActive) { - openLoadedChat(chat, chatModel) - } -} +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId) -fun openLoadedChat(chat: Chat, chatModel: ChatModel) { +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 apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { - val chatInfo = ch.chatInfo - val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) - val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return - if (chatModel.chatId.value != chat.id) return - chatModel.chatItems.addAll(0, chat.chatItems) -} - -suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { - val chatInfo = ch.chatInfo - val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return - if (chatModel.chatId.value != chat.id) return - chatModel.chatItems.replaceAll(chat.chatItems) +suspend fun apiFindMessages(ch: Chat, search: String) { + chatModel.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) { @@ -724,7 +702,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) if (ok && openChat) { - openDirectChat(rhId, contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId) } } }) { @@ -736,7 +714,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) if (ok && openChat) { - openDirectChat(rhId, contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId) } } }) { 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 d63e47bcdd..036768c6e7 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 @@ -231,7 +231,7 @@ fun ChatPreviewView( fun chatItemContentPreview(chat: Chat, ci: ChatItem?) { val mc = ci?.content?.msgContent val provider by remember(chat.id, ci?.id, ci?.file?.fileStatus) { - mutableStateOf({ providerForGallery(0, chat.chatItems, ci?.id ?: 0) {} }) + mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} }) } val uriHandler = LocalUriHandler.current when (mc) { 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 711fb1377d..0af8e7ca38 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,7 @@ fun onRequestAccepted(chat: Chat) { if (chatInfo is ChatInfo.Direct) { ModalManager.start.closeModals() if (chatInfo.contact.sndReady) { - openLoadedChat(chat, chatModel) + openLoadedChat(chat) } } } @@ -54,13 +54,13 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, showDel when (contactType) { ContactType.RECENT -> { withApi { - openChat(rhId, chat.chatInfo, chatModel) + openChat(rhId, chat.chatInfo) ModalManager.start.closeModals() } } ContactType.CHAT_DELETED -> { withApi { - openChat(rhId, chat.chatInfo, chatModel) + openChat(rhId, chat.chatInfo) ModalManager.start.closeModals() } } 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 d36bd255e3..3dd7e673c4 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 @@ -500,7 +500,7 @@ 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.clear() + chatModel.chatItems.clearAndNotify() withLongRunningApi { withChats { chats.clear() 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 e1d3d6541a..6cecbe4979 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.clear() + chatModel.chatItems.clearAndNotify() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 6c18e47df3..7cd272c109 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -409,7 +409,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co val c = chatModel.getContactChat(contact.contactId) if (c != null) { close?.invoke() - openDirectChat(rhId, contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId) } } } @@ -490,7 +490,7 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou val g = chatModel.getGroupChat(groupInfo.groupId) if (g != null) { close?.invoke() - openGroupChat(rhId, groupInfo.groupId, chatModel) + openGroupChat(rhId, groupInfo.groupId) } } } 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 673100bd8d..7236b22563 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -276,6 +276,8 @@ Message delivery error Message delivery warning Most likely this contact has deleted the connection with you. + No message + This message was deleted or not received yet. Error: %1$s diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt new file mode 100644 index 0000000000..18b17b25a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt @@ -0,0 +1,158 @@ +package chat.simplex.app + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatItemsMergerTest { + + @Test + fun testRecalculateSplitPositions() { + val oldItems = listOf(ChatItem.getSampleData(0), ChatItem.getSampleData(123L), ChatItem.getSampleData(124L), ChatItem.getSampleData(125L)) + + val splits1 = MutableStateFlow(listOf(123L)) + val chatState1 = ActiveChatState(splits = splits1) + val removed1 = listOf(oldItems[1]) + val newItems1 = oldItems - removed1 + val recalc1 = recalculateChatStatePositions(chatState1) + recalc1.removed(removed1.map { Triple(it.id, oldItems.indexOf(removed1[0]), it.isRcvNew) }, newItems1) + assertEquals(1, splits1.value.size) + assertEquals(124L, splits1.value.first()) + + val splits2 = MutableStateFlow(listOf(123L)) + val chatState2 = ActiveChatState(splits = splits2) + val removed2 = listOf(oldItems[1], oldItems[2]) + val newItems2 = oldItems - removed2 + val recalc2 = recalculateChatStatePositions(chatState2) + recalc2.removed(removed2.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed2[index]), it.isRcvNew) }, newItems2) + assertEquals(1, splits2.value.size) + assertEquals(125L, splits2.value.first()) + + val splits3 = MutableStateFlow(listOf(123L)) + val chatState3 = ActiveChatState(splits = splits3) + val removed3 = listOf(oldItems[1], oldItems[2], oldItems[3]) + val newItems3 = oldItems - removed3 + val recalc3 = recalculateChatStatePositions(chatState3) + recalc3.removed(removed3.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed3[index]), it.isRcvNew) }, newItems3) + assertEquals(0, splits3.value.size) + + val splits4 = MutableStateFlow(listOf(123L)) + val chatState4 = ActiveChatState(splits = splits4) + val recalc4 = recalculateChatStatePositions(chatState4) + recalc4.cleared() + assertEquals(0, splits4.value.size) + } + + @Test + fun testItemsMerging() { + val items = listOf( + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(100L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.Voice, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(99L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.FullDelete, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(98L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(97L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(96L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(95L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()), + ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(94L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()), + ) + + val unreadCount = mutableStateOf(0) + val chatState = ActiveChatState() + val merged1 = MergedItems.create(items, unreadCount, emptySet(), chatState) + assertEquals( + listOf( + listOf(0, false, + listOf( + listOf(0, 100, CIMergeCategory.ChatFeature), + listOf(1, 99, CIMergeCategory.ChatFeature) + ) + ), + listOf(2, false, + listOf( + listOf(0, 98, CIMergeCategory.RcvItemDeleted), + listOf(1, 97, CIMergeCategory.RcvItemDeleted) + ) + ), + listOf(4, true, + listOf( + listOf(0, 96, null), + ) + ), + listOf(5, true, + listOf( + listOf(0, 95, null), + ) + ), + listOf(6, true, + listOf( + listOf(0, 94, null) + ) + ) + ).toList().toString(), + merged1.items.map { + listOf( + it.startIndexInReversedItems, + if (it is MergedItem.Grouped) it.revealed else true, + when (it) { + is MergedItem.Grouped -> it.items.mapIndexed { index, listItem -> + listOf(index, listItem.item.id, listItem.item.mergeCategory) + } + is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory)) + } + ) + }.toString() + ) + + val merged2 = MergedItems.create(items, unreadCount, setOf(98L, 97L), chatState) + assertEquals( + listOf( + listOf(0, false, + listOf( + listOf(0, 100, CIMergeCategory.ChatFeature), + listOf(1, 99, CIMergeCategory.ChatFeature) + ) + ), + listOf(2, true, + listOf( + listOf(0, 98, CIMergeCategory.RcvItemDeleted), + ) + ), + listOf(3, true, + listOf( + listOf(0, 97, CIMergeCategory.RcvItemDeleted) + ) + ), + listOf(4, true, + listOf( + listOf(0, 96, null), + ) + ), + listOf(5, true, + listOf( + listOf(0, 95, null), + ) + ), + listOf(6, true, + listOf( + listOf(0, 94, null) + ) + ) + ).toList().toString(), + merged2.items.map { + listOf( + it.startIndexInReversedItems, + if (it is MergedItem.Grouped) it.revealed else true, + when (it) { + is MergedItem.Grouped -> it.items.mapIndexed { index, listItem -> + listOf(index, listItem.item.id, listItem.item.mergeCategory) + } + is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory)) + } + ) + }.toString() + ) + } +} 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 25d85a6b7d..2702862e47 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 @@ -56,7 +56,7 @@ fun showApp() { } else { // The last possible cause that can be closed chatModel.chatId.value = null - chatModel.chatItems.clear() + chatModel.chatItems.clearAndNotify() } chatModel.activeCall.value?.let { withBGApi { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index 2c063b5888..bdfbf6863f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState @@ -37,3 +38,6 @@ actual fun LocalWindowWidth(): Dp = with(LocalDensity.current) { simplexWindowState.windowState.size.width } } + +@Composable +actual fun LocalWindowHeight(): Dp = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } 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 a1df7091d6..3fa78bbbb5 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 @@ -45,7 +45,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState< val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo, chatModel) + openChat(chat.remoteHostId, chat.chatInfo) } } }, @@ -110,7 +110,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo, chatModel) + openChat(chat.remoteHostId, chat.chatInfo) } } },