mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-26 17:27:57 +00:00
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:
committed by
GitHub
parent
d1ae3ba2d3
commit
2b155db57d
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -429,8 +429,8 @@ fun ComposeView(
|
||||
ttl = ttl
|
||||
)
|
||||
|
||||
chatItems?.forEach { chatItem ->
|
||||
withChats {
|
||||
withChats {
|
||||
chatItems?.forEach { chatItem ->
|
||||
addChatItem(rhId, chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user