android, desktop: open chat on first unread, "scroll" to quoted items that were not loaded (#5140)

* android, desktop: infinity scroll rework

* group corrections

* scroll to quote/unread/top/bottom

* changes

* changes

* changes

* changes

* better

* changes

* fix chat closing on desktop

* fix reading items counter, scrolling to newly appeared message, removed unneeded items loading, only partially visible items marked read

* workaround of showing buttom with arrow down on new messages receiving

* rename param

* fix tests

* comments and removed unused code

* performance optimization

* optimization for loading more items in small chat

* fix loading prev items in loop

* workaround to blinking button with counter

* terminal scroll fix

* different click events for floating buttons

* refactor

* change

* WIP

* refactor

* refactor

* renames

* refactor

* refactor

* change

* mark read problem fix

* fix tests

* fix auto scroll in some situations

* fix scroll to quote when it's near the top loaded area

* refactor

* refactor

* rename

* rename

* fix

* alert when quoted message doesn't exist

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2024-11-21 02:23:55 +07:00
committed by GitHub
parent d1ae3ba2d3
commit 2b155db57d
28 changed files with 1575 additions and 395 deletions

View File

@@ -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
}

View File

@@ -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() }
}

View File

@@ -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<String?>(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<ChatItem>())
// 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<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
@@ -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<Chat>()
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<Long>()
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<Int?, ChatItem?> {
val i = getChatItemIndexOrNull(ci)
return if (i != null) {
val reversedChatItems = chatItems.asReversed()
i to if (i > 0) reversedChatItems[i - 1] else null
} else {
null to null
}
}
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
@@ -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<Long>?, newItems: List<ChatItem>)
fun added(item: Pair<Long, Boolean>, index: Int)
// itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not)
fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>)
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 <T> MutableState<SnapshotStateList<T>>.add(index: Int, elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(index, elem) }
fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}
fun <T> MutableState<SnapshotStateList<T>>.add(elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(elem) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) }
}
fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(elem) }
}
// For some reason, Kotlin version crashes if the list is empty
fun <T> MutableList<T>.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<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) }
}
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
@@ -2295,28 +2343,59 @@ fun <T> MutableState<SnapshotStateList<T>>.addAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(value); addAll(elems) }
}
fun <T> MutableState<SnapshotStateList<T>>.removeAll(block: (T) -> Boolean) {
value = SnapshotStateList<T>().apply { addAll(value); removeAll(block) }
fun MutableState<SnapshotStateList<Chat>>.removeAll(block: (Chat) -> Boolean) {
value = SnapshotStateList<Chat>().apply { addAll(value); removeAll(block) }
}
fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
val new = SnapshotStateList<T>()
// Removes item(s) from chatItems and notifies a listener about removed item(s)
fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatItem) -> Boolean) {
val toRemove = ArrayList<Triple<Long, Int, Boolean>>()
value = SnapshotStateList<ChatItem>().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<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
val new = SnapshotStateList<Chat>()
new.addAll(value)
val res = new.removeAt(index)
value = new
return res
}
fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
val removed: Triple<Long, Int, Boolean>
value = SnapshotStateList<ChatItem>().apply {
addAll(value)
val remIndex = lastIndex
val rem = removeLast()
removed = Triple(rem.id, remIndex, rem.isRcvNew)
}
chatItemsChangesListener?.removed(listOf(removed), value)
}
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(elems) }
}
fun <T> MutableState<SnapshotStateList<T>>.clear() {
value = SnapshotStateList<T>()
fun MutableState<SnapshotStateList<Chat>>.clear() {
value = SnapshotStateList()
}
// Removes all chatItems and notifies a listener about it
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
value = SnapshotStateList()
chatItemsChangesListener?.cleared()
}
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()

View File

@@ -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<Chat, NavigationInfo>? {
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<Chat>): 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)}")

View File

@@ -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)
}
}

View File

@@ -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<Dp>) {
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<Dp>) {
}
}
}
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
}
}
}

View File

@@ -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()
}
},

View File

@@ -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<ChatItem>()
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<Long>,
val newSplits: List<Long>,
)
private fun removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId: StateFlow<Long>,
newItems: SnapshotStateList<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
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<Long>()
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<ChatItem>,
newIds: Set<Long>,
chat: Chat,
splits: StateFlow<List<Long>>
): Pair<List<Long>, Int> {
var unreadInLoaded = unreadInLoaded
var firstItemIdBelowAllSplits: Long? = null
val splitsToRemove = ArrayList<Long>()
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<Long> = 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<ChatItem>,
chat: Chat,
splits: StateFlow<List<Long>>,
visibleItemIndexesNonReversed: () -> IntRange
): List<Long> {
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<MutableSet<Long>>()
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<ChatItem>): Pair<Set<Long>, Int> {
var unreadInLoaded = 0
val ids = mutableSetOf<Long>()
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<ChatItem>, chat: Chat) {
val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains(it.id) }
}

View File

@@ -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<MergedItem>,
val splits: List<SplitRange>,
// chat item id, index in list
val indexInParentItems: Map<Long, Int>,
) {
companion object {
fun create(items: List<ChatItem>, unreadCount: State<Int>, revealedItems: Set<Long>, chatState: ActiveChatState): MergedItems {
if (items.isEmpty()) return MergedItems(emptyList(), emptyList(), emptyMap())
val unreadAfterItemId = chatState.unreadAfterItemId
val itemSplits = chatState.splits.value
val mergedItems = ArrayList<MergedItem>()
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
val splitRanges = ArrayList<SplitRange>()
val indexInParentItems = mutableMapOf<Long, Int>()
var index = 0
var unclosedSplitIndex: Int? = null
var unclosedSplitIndexInParent: Int? = null
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount.value - chatState.unreadAfterNewestLoaded.value
var lastRevealedIdsInMergedItems: MutableList<Long>? = null
var lastRangeInReversedForMergedItems: MutableStateFlow<IntRange>? = 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<ListItem>,
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<Long>,
val rangeInReversed: MutableStateFlow<IntRange>,
val mergeCategory: CIMergeCategory?,
val unreadIds: MutableSet<Long>,
override val startIndexInReversedItems: Int,
): MergedItem() {
fun reveal(reveal: Boolean, revealedItems: MutableState<Set<Long>>) {
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<List<Long>> = MutableStateFlow(emptyList()),
val unreadAfterItemId: MutableStateFlow<Long> = MutableStateFlow(-1L),
// total items after unread after item (exclusive)
val totalAfter: MutableStateFlow<Int> = MutableStateFlow(0),
val unreadTotal: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
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<ChatItem>) {
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<MergedItems>, 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<Long>?, newItems: List<ChatItem>) {
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<Long, Boolean>, index: Int) {
if (item.second) {
chatState.unreadAfter.value++
chatState.unreadTotal.value++
}
}
override fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>) {
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState
val newSplits = ArrayList<Long>()
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()

View File

@@ -429,8 +429,8 @@ fun ComposeView(
ttl = ttl
)
chatItems?.forEach { chatItem ->
withChats {
withChats {
chatItems?.forEach { chatItem ->
addChatItem(rhId, chat.chatInfo, chatItem)
}
}

View File

@@ -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())

View File

@@ -21,7 +21,7 @@ fun CIChatFeatureView(
feature: Feature,
iconColor: Color,
icon: Painter? = null,
revealed: MutableState<Boolean>,
revealed: State<Boolean>,
showMenu: MutableState<Boolean>,
) {
val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList()

View File

@@ -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(

View File

@@ -56,8 +56,8 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
revealed: MutableState<Boolean>,
range: IntRange?,
revealed: State<Boolean>,
range: State<IntRange?>,
selectedChatItems: MutableState<Set<Long>?>,
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<Boolean>,
revealed: State<Boolean>,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit,
@@ -700,48 +701,48 @@ fun SelectItemAction(
}
@Composable
private fun RevealItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
private fun RevealItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, 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<Boolean>, showMenu: MutableState<Boolean>) {
private fun HideItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, 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<Boolean>, showMenu: MutableState<Boolean>) {
private fun ExpandItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, 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<Boolean>, showMenu: MutableState<Boolean>) {
private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, 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,

View File

@@ -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<Boolean> {

View File

@@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, 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<Boolean>) {
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
val ciCategory = chatItem.mergeCategory
val text = if (!revealed.value && ciCategory != null && i != null) {

View File

@@ -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<Boolean>) {
@@ -71,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
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<Boolean>) {
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<Boolean>) {
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)
}
}
}) {

View File

@@ -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) {

View File

@@ -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<Boolean>, 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()
}
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -276,6 +276,8 @@
<string name="message_delivery_error_title">Message delivery error</string>
<string name="message_delivery_warning_title">Message delivery warning</string>
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
<string name="message_deleted_or_not_received_error_title">No message</string>
<string name="message_deleted_or_not_received_error_desc">This message was deleted or not received yet.</string>
<!-- CIStatus errors -->
<string name="ci_status_other_error">Error: %1$s</string>

View File

@@ -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()
)
}
}

View File

@@ -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 {

View File

@@ -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() }

View File

@@ -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)
}
}
},