Compare commits

...

76 Commits

Author SHA1 Message Date
Diogo 0a22706d01 comments 2024-11-04 10:28:55 +00:00
Diogo bfac8bd58a fix repeated loading on bottom section 2024-11-04 09:21:08 +00:00
Avently 34c10fc12f refactor 2024-11-04 13:18:04 +07:00
Avently 22fc2e5151 refactor 2024-11-04 12:51:29 +07:00
Avently 2396531414 refactor 2024-11-04 12:35:34 +07:00
Diogo a3906cf88f reduce looping 2024-11-02 22:27:20 +00:00
Avently d03fc53151 one more place 2024-11-02 12:17:14 +07:00
Avently 69138dd99f fix rememberSaveable on Android 2024-11-02 11:48:53 +07:00
Diogo f9d01f6f8e fix 2024-11-01 23:16:05 +00:00
Diogo b90f7a932f Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-11-01 22:12:06 +00:00
Diogo 40db6686c1 throw when search is sent for initial 2024-11-01 22:11:10 +00:00
Diogo 21f9c7fd50 Revert "propagate search"
This reverts commit 01611fd719.
2024-11-01 21:57:26 +00:00
Diogo d15560a29f fix scroll to bottom race condition after a few lands 2024-11-01 21:26:33 +00:00
Diogo f197c4174c stability improvements and race conditions 2024-11-01 18:20:05 +00:00
Diogo 99719088e5 Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-11-01 16:10:32 +00:00
Diogo 01611fd719 propagate search 2024-11-01 16:09:12 +00:00
Diogo 0ce7d16a59 refactor: use foldr everywhere 2024-11-01 16:00:38 +00:00
Diogo bf20b73893 refactor to make landingSection reusable 2024-11-01 15:56:15 +00:00
Diogo c9ae77e9f3 Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-11-01 15:17:12 +00:00
Diogo d875fc99f4 revealedItemCount while loop 2024-11-01 15:03:57 +00:00
Diogo 1c4c48f168 prepareItems perf and fixes 2024-11-01 14:52:35 +00:00
Diogo 122d5948af consistent type for chatItemsSectionArea 2024-11-01 14:13:36 +00:00
spaced4ndy fd5e5d295f foldr 2024-11-01 17:30:58 +04:00
Diogo 3a2e59a6e8 fix dropTemporarySections 2024-11-01 12:36:37 +00:00
Avently 494724de01 one more 2024-11-01 19:09:54 +07:00
Avently 089e5f6ec1 fix stale chatInfo 2024-11-01 19:05:42 +07:00
Diogo 74c6a425b3 fix reveals and other range operations, performance improvement 2024-11-01 10:45:57 +00:00
Diogo 0698f4acfc revealed fixed 2024-11-01 10:06:16 +00:00
Avently 779a91f5cb removeIf -> removeAll 2024-11-01 15:00:21 +07:00
Avently 20c4c02f63 fix 2024-11-01 14:59:38 +07:00
Avently d2a473c908 refactor 2024-11-01 14:31:16 +07:00
Avently 01d0c4ef96 Merge remote-tracking branch 'origin/dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-11-01 13:57:24 +07:00
Diogo bab2341406 more concise pagination handling 2024-10-31 23:40:08 +00:00
Diogo c98e8e774f single api for simple pagination loads 2024-10-31 22:55:01 +00:00
Diogo cf32d1d950 simplify 2024-10-31 22:40:28 +00:00
Diogo 065cc170da Merge branch 'dc/core-pagination' into dc/core-initial-landing-for-chat 2024-10-31 22:08:30 +00:00
Diogo a84b56d39d Merge branch 'master' into dc/core-pagination 2024-10-31 22:07:59 +00:00
Diogo 937e66b3b8 fix occasional race condition on long navigation 2024-10-31 16:55:53 +00:00
Diogo 638b237b8c Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-10-31 16:11:05 +00:00
Diogo 37a5c353ea descriptive view message 2024-10-31 16:09:56 +00:00
Diogo 31eca1687c total accuracy on landing section 2024-10-31 16:03:45 +00:00
Diogo 7e9ab7cd85 avoid recompose when revealed is clean 2024-10-31 16:02:00 +00:00
Diogo 8ff2f66bb9 disable auto scroll when loading bottom section 2024-10-31 14:58:45 +00:00
Diogo e2de454426 fix mark read for ranges 2024-10-31 13:24:56 +00:00
Diogo f87a4a43c9 tracking positions correctly on scroll 2024-10-31 11:42:22 +00:00
Diogo 83ce1f5808 Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-10-31 10:50:56 +00:00
Diogo 4bbee43602 fix ChatLandingSection serialized type 2024-10-31 10:50:13 +00:00
Diogo 0ee2769e15 fixed enum 2024-10-31 10:49:04 +00:00
Diogo 82e6b2eefc fixed bottom section load 2024-10-31 10:10:05 +00:00
Diogo 97894c7f53 load bottom section if loaded section is unread 2024-10-30 22:20:51 +00:00
Diogo ff42ca69de open on first unread message 2024-10-30 21:48:01 +00:00
Diogo 5ccf00f70e Merge branch 'dc/core-initial-landing-for-chat' into dc/android-desktop-infinite-scroll 2024-10-30 16:09:51 +00:00
Diogo 22be251862 refactor names 2024-10-30 16:08:40 +00:00
Diogo 84bd23a99d api for landing section 2024-10-30 16:07:36 +00:00
Diogo 9737f6baa6 multiple destination section jumps 2024-10-30 12:38:31 +00:00
Diogo 39e3a8cb70 trim section on jump to bottom 2024-10-29 21:56:43 +00:00
Diogo 649561fbc9 refactor 2024-10-29 21:27:23 +00:00
Diogo c681c89657 not use snapshot inside derived state of to avoid crash 2024-10-29 21:22:46 +00:00
Diogo 7ad0a2dd87 remove other sections when jump to bottom clicked 2024-10-29 16:52:43 +00:00
Diogo 71d891acc2 scroll to bottom safer 2024-10-29 16:28:53 +00:00
Diogo 7faa5b4f6a fix reveal and hide items 2024-10-29 16:03:05 +00:00
Diogo c93ed87228 fix group avatar show 2024-10-29 14:11:43 +00:00
Diogo 2b170830df add item separation and date back 2024-10-29 14:01:44 +00:00
Diogo 19755e359d scroll down fixed 2024-10-29 11:35:11 +00:00
Diogo 53c77d310a fetch both sides poc 2024-10-28 22:23:48 +00:00
Diogo 796d3916bd track scroll direction 2024-10-27 16:01:04 +00:00
Diogo b93abb9555 jump to quote base 2024-10-27 15:47:21 +00:00
Diogo c31560238a basic structure 2024-10-27 14:42:32 +00:00
Diogo 2b7ad80dfd fixed sqls 2024-10-27 00:25:05 +01:00
Diogo d8f5b6e813 controller parse 2024-10-27 00:01:52 +01:00
Diogo b6359559c1 support for initial 2024-10-26 23:44:43 +01:00
Diogo 5826d14866 Merge branch 'dc/core-pagination' into dc/core-initial-landing-for-chat 2024-10-26 22:22:04 +01:00
Diogo a9ca467a80 Merge branch 'master' into dc/core-pagination 2024-10-26 22:21:44 +01:00
Diogo 185e307e70 initial work on initial param for loading chat 2024-10-25 17:12:23 +01:00
Diogo f738d2731c core: initial api support for items around a given item (#5092)
* core: initial api support for items around a given item

* implementation and tests for local messages

* pass entities down

* unused

* getAllChatItems implementation and tests

* pagination for getting chat and tests

* remove unused import

* group implementation and tests

* refactor

* order by created at for local and direct chats
2024-10-25 16:30:16 +01:00
Diogo ea61fd7e2b core: auto increment chat item ids (#5088)
* core: auto increment chat item ids

* file name

* down name

* update schema

* ignore down migration on schema dump test

* fix testDirectMessageDelete test

* fix testNotes test
2024-10-25 16:30:04 +01:00
23 changed files with 1210 additions and 450 deletions
@@ -11,6 +11,7 @@ import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.* import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ChatSectionArea
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToDeviceState
@@ -65,6 +66,8 @@ object ChatModel {
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>()) val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
// chatItemId, SectionArea
val chatItemsSectionArea = mutableMapOf<Long, ChatSectionArea>()
// rhId, chatId // rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList()) val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>() val chatItemStatuses = mutableMapOf<Long, CIStatus>()
@@ -329,6 +332,7 @@ object ChatModel {
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
// Prevent situation when chat item already in the list received from backend // Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.none { it.id == cItem.id }) {
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem) chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else { } else {
@@ -377,6 +381,7 @@ object ChatModel {
} else { } else {
cItem cItem
} }
chatItemsSectionArea[ci.id] = ChatSectionArea.Bottom
chatItems.add(ci) chatItems.add(ci)
true true
} }
@@ -608,6 +613,7 @@ object ChatModel {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chatItems.add(cItem) chatItems.add(cItem)
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
} }
return cItem return cItem
} }
@@ -615,6 +621,7 @@ object ChatModel {
fun removeLiveDummy() { fun removeLiveDummy() {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLast() chatItems.removeLast()
chatItemsSectionArea.remove(ChatItem.TEMP_LIVE_CHAT_ITEM_ID)
} }
} }
@@ -2307,6 +2314,10 @@ fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
return res return res
} }
fun <T> MutableState<SnapshotStateList<T>>.removeRange(fromIndex: Int, toIndex: Int) {
value = SnapshotStateList<T>().apply { addAll(value); removeRange(fromIndex, toIndex) }
}
fun <T> MutableState<SnapshotStateList<T>>.removeLast() { fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
value = SnapshotStateList<T>().apply { addAll(value); removeLast() } value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
} }
@@ -865,11 +865,15 @@ object ChatController {
return emptyList() 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 = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Pair<Chat, ChatLandingSection>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) 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) Pair(r.chat, r.section) else Pair(r.chat.copy(remoteHostId = rh), r.section)
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_get_chat_item_not_found_title), generalGetString(MR.strings.failed_to_get_chat_item_not_found_description))
} else {
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))
}
return null return null
} }
@@ -3468,11 +3472,15 @@ sealed class ChatPagination {
class Last(val count: Int): ChatPagination() class Last(val count: Int): ChatPagination()
class After(val chatItemId: Long, val count: Int): ChatPagination() class After(val chatItemId: Long, val count: Int): ChatPagination()
class Before(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) { val cmdString: String get() = when (this) {
is Last -> "count=${this.count}" is Last -> "count=${this.count}"
is After -> "after=${this.chatItemId} count=${this.count}" is After -> "after=${this.chatItemId} count=${this.count}"
is Before -> "before=${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 { companion object {
@@ -4857,7 +4865,10 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul
} else if (type == "apiChat") { } else if (type == "apiChat") {
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
val chat = parseChatData(resp["chat"]!!) val chat = parseChatData(resp["chat"]!!)
return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) val section = resp["section"]?.let {
json.decodeFromJsonElement<ChatLandingSection>(it)
} ?: ChatLandingSection.Latest
return APIResponse(CR.ApiChat(user, chat, section), remoteHostId, corr)
} else if (type == "chatCmdError") { } else if (type == "chatCmdError") {
val userObject = resp["user_"]?.jsonObject val userObject = resp["user_"]?.jsonObject
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull() val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
@@ -4914,7 +4925,7 @@ sealed class CR {
@Serializable @SerialName("chatRunning") class ChatRunning: CR() @Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): 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 section: ChatLandingSection): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): 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("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() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@@ -5264,7 +5275,7 @@ sealed class CR {
is ChatRunning -> noDetails() is ChatRunning -> noDetails()
is ChatStopped -> noDetails() is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, json.encodeToString(chat)) is ApiChat -> withUser(user, "section: ${json.encodeToString(section)}\n${json.encodeToString(chat)}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
@@ -5504,6 +5515,12 @@ sealed class GroupLinkPlan {
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
} }
@Serializable
enum class ChatLandingSection {
@SerialName("latest") Latest,
@SerialName("unread") Unread,
}
abstract class TerminalItem { abstract class TerminalItem {
abstract val id: Long abstract val id: Long
abstract val remoteHostId: Long? abstract val remoteHostId: Long?
@@ -0,0 +1,287 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.toMutableStateList
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatController
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.max
const val MAX_SECTION_SIZE = 500
enum class ChatSectionArea {
Bottom,
Current,
Destination
}
data class ChatSectionAreaBoundary (
var minIndex: Int,
var maxIndex: Int,
val area: ChatSectionArea
)
data class ChatSection (
val items: MutableList<SectionItems>,
val boundary: ChatSectionAreaBoundary,
// chatItemId, index in rendered LazyColumn
val itemPositions: MutableMap<Long, Int>
)
data class SectionItems (
val mergeCategory: CIMergeCategory?,
val items: MutableList<ChatItem>,
val revealed: Boolean,
val showAvatar: MutableSet<Long>,
var originalItemsRange: IntRange
)
data class ChatSectionLoader (
val position: Int,
val sectionArea: ChatSectionArea
) {
fun prepareItems(items: List<ChatItem>): List<ChatItem> {
val chatItemsSectionArea = chatModel.chatItemsSectionArea
val itemsToAdd = mutableListOf<ChatItem>()
val sectionsToMerge = mutableMapOf<ChatSectionArea, ChatSectionArea>()
val itemsThatCouldRequireMerge = mutableListOf<ChatItem>()
for (cItem in items) {
val itemSectionArea = chatItemsSectionArea[cItem.id]
if (itemSectionArea == null) {
itemsToAdd.add(cItem)
val targetSection = sectionsToMerge[sectionArea] ?: this.sectionArea
chatItemsSectionArea[cItem.id] = targetSection
if (targetSection == this.sectionArea) {
itemsThatCouldRequireMerge.add(cItem)
}
} else if (itemSectionArea != this.sectionArea) {
val (targetSection, sectionToDrop) = when (itemSectionArea) {
ChatSectionArea.Bottom -> ChatSectionArea.Bottom to this.sectionArea
ChatSectionArea.Current -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom to itemSectionArea else itemSectionArea to this.sectionArea
ChatSectionArea.Destination -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom to itemSectionArea else itemSectionArea to this.sectionArea
}
if (targetSection != sectionToDrop) {
sectionsToMerge[sectionToDrop] = targetSection
}
}
}
if (sectionsToMerge.isNotEmpty()) {
chatModel.chatItems.value.forEach {
val currentSection = chatItemsSectionArea[it.id]
val newSection = sectionsToMerge[currentSection]
if (newSection != null) {
chatItemsSectionArea[it.id] = newSection
}
}
itemsThatCouldRequireMerge.forEach {
val targetSection = sectionsToMerge[sectionArea] ?: sectionArea
chatItemsSectionArea[it.id] = targetSection
}
}
return itemsToAdd
}
}
fun ChatSection.getPreviousShownItem(sectionIndex: Int, itemIndex: Int): ChatItem? {
val section = items.getOrNull(sectionIndex) ?: return null
return if (section.mergeCategory == null) {
section.items.getOrNull(itemIndex + 1) ?: items.getOrNull(sectionIndex + 1)?.items?.firstOrNull()
} else {
items.getOrNull(sectionIndex + 1)?.items?.firstOrNull()
}
}
fun ChatSection.getNextShownItem(sectionIndex: Int, itemIndex: Int): ChatItem? {
val section = items.getOrNull(sectionIndex) ?: return null
return if (section.mergeCategory == null) {
section.items.getOrNull(itemIndex - 1) ?: items.getOrNull(sectionIndex - 1)?.items?.lastOrNull()
} else {
items.getOrNull(sectionIndex - 1)?.items?.lastOrNull()
}
}
fun List<ChatItem>.putIntoSections(revealedItems: Set<Long>): List<ChatSection> {
if (isEmpty()) return emptyList()
val chatItemsSectionArea = chatModel.chatItemsSectionArea
val sections = mutableListOf<ChatSection>()
val first = this[0]
val showAvatar = mutableSetOf<Long>()
if (first.chatDir is CIDirection.GroupRcv) {
val second = getOrNull(1)
if (second != null) {
if (second.chatDir !is CIDirection.GroupRcv || second.chatDir.groupMember.memberId != first.chatDir.groupMember.memberId) {
showAvatar.add(first.id)
}
} else {
showAvatar.add(first.id)
}
}
var recent = SectionItems(
mergeCategory = first.mergeCategory,
items = mutableListOf(first),
revealed = first.mergeCategory == null || revealedItems.contains(first.id),
showAvatar = showAvatar,
originalItemsRange = 0..0
)
val area = chatItemsSectionArea[recent.items[0].id] ?: ChatSectionArea.Bottom
sections.add(
ChatSection(
items = mutableListOf(recent),
boundary = ChatSectionAreaBoundary(minIndex = 0, maxIndex = 0, area = area),
itemPositions = mutableMapOf(recent.items[0].id to 0)
)
)
var prev = this[0]
var index = 0
var positionInList = 0;
while (index < size) {
if (index == 0) {
index++
continue
}
val item = this[index]
val itemArea = chatItemsSectionArea[item.id] ?: ChatSectionArea.Bottom
val existingSection = sections.find { it.boundary.area == itemArea }
if (existingSection == null) {
positionInList++
val newSection = SectionItems(
mergeCategory = item.mergeCategory,
items = mutableListOf(item),
revealed = item.mergeCategory == null || revealedItems.contains(item.id),
showAvatar = mutableSetOf(item.id),
originalItemsRange = index..index
)
sections.add(
ChatSection(
items = mutableListOf(newSection),
boundary = ChatSectionAreaBoundary(minIndex = index, maxIndex = index, area = itemArea),
itemPositions = mutableMapOf(item.id to positionInList)
)
)
} else {
recent = existingSection.items.last()
val category = item.mergeCategory
if (recent.mergeCategory == category) {
if (category == null || recent.revealed || revealedItems.contains(item.id)) {
positionInList++
}
if (item.chatDir is CIDirection.GroupRcv && prev.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.memberId != (prev.chatDir as CIDirection.GroupRcv).groupMember.memberId) {
recent.showAvatar.add(item.id)
}
recent.items.add(item)
recent.originalItemsRange = recent.originalItemsRange.first..index
existingSection.itemPositions[item.id] = positionInList
} else {
positionInList++
val newSectionItems = SectionItems(
mergeCategory = item.mergeCategory,
items = mutableListOf(item),
revealed = item.mergeCategory == null || revealedItems.contains(item.id),
showAvatar = if (item.chatDir is CIDirection.GroupRcv && (prev.chatDir !is CIDirection.GroupRcv || (prev.chatDir as CIDirection.GroupRcv).groupMember.memberId != item.chatDir.groupMember.memberId)) {
mutableSetOf(item.id)
} else {
mutableSetOf()
},
originalItemsRange = index..index
)
existingSection.itemPositions[item.id] = positionInList
existingSection.items.add(newSectionItems)
}
existingSection.boundary.maxIndex = index
}
prev = item
index++
}
return sections
}
fun List<ChatSection>.chatItemPosition(chatItemId: Long): Int? {
for (section in this) {
val position = section.itemPositions[chatItemId]
if (position != null) {
return position
}
}
return null
}
fun List<ChatSection>.revealedItemCount(): Int {
var count = 0
for (section in this) {
var i = 0;
while (i < section.items.size) {
val item = section.items[i]
if (item.revealed) {
count += item.items.size
} else {
count++
}
i++
}
}
return count
}
fun List<ChatSection>.dropTemporarySections() {
val bottomSection = this.find { it.boundary.area == ChatSectionArea.Bottom }
if (bottomSection != null) {
val itemsOutsideOfSection = chatModel.chatItems.value.lastIndex - bottomSection.boundary.maxIndex
chatModel.chatItems.removeRange(fromIndex = 0, toIndex = itemsOutsideOfSection + bottomSection.excessItemCount())
chatModel.chatItemsSectionArea.clear()
chatModel.chatItems.value.associateTo(chatModel.chatItemsSectionArea) { it.id to ChatSectionArea.Bottom }
}
}
private fun ChatSection.excessItemCount(): Int {
return max(boundary.maxIndex - boundary.minIndex + 1 - MAX_SECTION_SIZE, 0)
}
fun landingSectionToArea(chatLandingSection: ChatLandingSection) = when (chatLandingSection) {
ChatLandingSection.Latest -> ChatSectionArea.Bottom
ChatLandingSection.Unread -> ChatSectionArea.Current
}
suspend fun apiLoadBottomSection(chatInfo: ChatInfo, rhId: Long?) {
val chat = chatController.apiGetChat(rh = rhId, type = chatInfo.chatType, id = chatInfo.apiId)
if (chatModel.chatId.value != chatInfo.id || chat == null) return
withContext(Dispatchers.Main) {
val updatedItems = chatModel.chatItems.value.toMutableStateList()
var insertIndex = updatedItems.size
var needsMerge = false
for (cItem in chat.first.chatItems.asReversed()) {
if (chatModel.chatItemsSectionArea[cItem.id] == null) {
updatedItems.add(insertIndex, cItem)
chatModel.chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
} else {
needsMerge = true
chatModel.chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
insertIndex = max(0, insertIndex - 1)
}
}
if (needsMerge) {
updatedItems.associateTo(chatModel.chatItemsSectionArea) { it.id to ChatSectionArea.Bottom }
}
chatModel.chatItems.replaceAll(updatedItems)
}
}
@@ -47,8 +47,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.datetime.* import kotlinx.datetime.*
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import kotlin.math.abs import kotlin.math.*
import kotlin.math.sign
data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?) data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?)
@@ -292,13 +291,35 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
} }
} }
}, },
loadPrevMessages = { chatId -> loadMessages = { chatId, scrollDirection, (itemId, idx, area) ->
val c = chatModel.getChat(chatId) val c = chatModel.getChat(chatId) ?: return@ChatLayout
if (chatModel.chatId.value != chatId) return@ChatLayout if (chatModel.chatId.value != chatId) return@ChatLayout
val firstId = chatModel.chatItems.value.firstOrNull()?.id withBGApi {
if (c != null && firstId != null) { when (scrollDirection) {
withBGApi { ScrollDirection.Up -> {
apiLoadPrevMessages(c, chatModel, firstId, searchText.value) val chatSectionLoader = ChatSectionLoader(idx, area)
apiLoadMessages(
rhId = c.remoteHostId,
chatInfo = c.chatInfo,
chatModel = chatModel,
itemId = itemId,
search = "",
chatSectionLoader = chatSectionLoader,
)
}
ScrollDirection.Down -> {
val chatSectionLoader = ChatSectionLoader(idx + 1, area)
apiLoadMessages(
rhId = c.remoteHostId,
chatInfo = c.chatInfo,
chatModel = chatModel,
itemId = itemId,
search = "",
chatSectionLoader = chatSectionLoader,
pagination = ChatPagination.After(itemId, ChatPagination.PRELOAD_COUNT)
)
}
else -> {}
} }
} }
}, },
@@ -583,7 +604,7 @@ fun ChatLayout(
back: () -> Unit, back: () -> Unit,
info: () -> Unit, info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatId) -> Unit, loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit, deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit, receiveFile: (Long) -> Unit,
@@ -611,7 +632,7 @@ fun ChatLayout(
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean, developerTools: Boolean,
showViaProxy: Boolean, showViaProxy: Boolean,
showSearch: MutableState<Boolean> showSearch: MutableState<Boolean>,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
@@ -649,10 +670,10 @@ fun ChatLayout(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
ChatItemsList( ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy, setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy
) )
} }
} }
@@ -922,7 +943,7 @@ fun BoxScope.ChatItemsList(
linkMode: SimplexLinkMode, linkMode: SimplexLinkMode,
selectedChatItems: MutableState<Set<Long>?>, selectedChatItems: MutableState<Set<Long>?>,
showMemberInfo: (GroupInfo, GroupMember) -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatId) -> Unit, loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit, deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit, receiveFile: (Long) -> Unit,
@@ -943,11 +964,12 @@ fun BoxScope.ChatItemsList(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean, developerTools: Boolean,
showViaProxy: Boolean showViaProxy: Boolean,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
ScrollToBottom(chatInfo.id, listState, chatModel.chatItems) val scrollAdjustmentEnabled = remember { mutableStateOf(true) }
ScrollToBottom(chatInfo.id, listState, chatModel.chatItems, scrollAdjustmentEnabled)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back // Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) { LaunchedEffect(searchValue.value.isEmpty()) {
@@ -961,21 +983,106 @@ fun BoxScope.ChatItemsList(
} }
} }
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } } val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
val sections = remember { derivedStateOf { reversedChatItems.value.putIntoSections(revealedItems.value) } }
val preloadItemsEnabled = remember { mutableStateOf(true) }
val boundaries = remember { derivedStateOf { sections.value.map { it.boundary } } }
val scrollPosition: State<(Int) -> Int> = remember { mutableStateOf({ idx -> min(sections.value.revealedItemCount() - 1, idx + 1 ) }) }
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, preloadItemsEnabled, boundaries, loadMessages)
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() }) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() })
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val chatInfoUpdated = rememberUpdatedState(chatInfo)
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatInfoUpdated.value.id }
.distinctUntilChanged()
.collect {
if (revealedItems.value.isNotEmpty()) {
revealedItems.value = setOf()
}
val firstUnreadItem = reversedChatItems.value.findLast { it.isRcvNew }
if (firstUnreadItem != null) {
val firstUnreadItemIndexIdx = sections.value.chatItemPosition(firstUnreadItem.id)
if (firstUnreadItemIndexIdx != null) {
scrollAdjustmentEnabled.value = false
listState.scrollToItem(scrollPosition.value(firstUnreadItemIndexIdx), -maxHeight.value)
}
if (chatModel.chatItemsSectionArea[firstUnreadItem.id] != ChatSectionArea.Bottom) {
withBGApi {
scrollAdjustmentEnabled.value = false
try {
apiLoadBottomSection(chatInfoUpdated.value, remoteHostId)
} finally {
delay(600)
scrollAdjustmentEnabled.value = true
preloadItemsEnabled.value = true
}
}
} else {
scrollAdjustmentEnabled.value = true
}
}
preloadItemsEnabled.value = true
}
}
}
val scrollToItem: State<(Long) -> Unit> = remember { val scrollToItem: State<(Long) -> Unit> = remember {
mutableStateOf( mutableStateOf({ itemId: Long ->
{ itemId: Long -> val index = sections.value.chatItemPosition(itemId)
val index = reversedChatItems.value.indexOfFirst { it.id == itemId } preloadItemsEnabled.value = false
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) } if (index != null) {
scope.launch {
listState.animateScrollToItem(scrollPosition.value(index), -maxHeight.value)
preloadItemsEnabled.value = true
}
} else {
withBGApi {
try {
val destinationSection = sections.value.find { it.boundary.area == ChatSectionArea.Destination }
val itemsToDrop = destinationSection?.items?.flatMap { it.items }?.toList()
withContext(Dispatchers.Main) {
itemsToDrop?.forEach {
chatModel.chatItemsSectionArea[it.id] = ChatSectionArea.Current
}
}
val chatSectionLoader = ChatSectionLoader(0, ChatSectionArea.Destination)
apiLoadMessages(
rhId = remoteHostId,
chatInfo = chatInfoUpdated.value,
chatModel = chatModel,
itemId = itemId,
search = "",
chatSectionLoader = chatSectionLoader,
pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2)
)
val idx = sections.value.chatItemPosition(itemId)
scope.launch {
if (idx != null) {
listState.animateScrollToItem(scrollPosition.value(idx), -maxHeight.value)
if (!itemsToDrop.isNullOrEmpty()) {
itemsToDrop.forEach {
chatModel.chatItemsSectionArea.remove(it.id)
}
chatModel.chatItems.removeAll { chatModel.chatItemsSectionArea[it.id] == null }
val newIdx = reversedChatItems.value.indexOfFirst { it.id == itemId }
listState.scrollToItem(scrollPosition.value(newIdx), -maxHeight.value)
}
}
preloadItemsEnabled.value = true
}
} catch (ex: Exception) {
preloadItemsEnabled.value = true
}
} }
} }
) })
} }
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once // TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
LaunchedEffect(chatInfo.id) { LaunchedEffect(chatInfo.id) {
@@ -993,23 +1100,14 @@ fun BoxScope.ChatItemsList(
VideoPlayerHolder.releaseAll() VideoPlayerHolder.releaseAll()
} }
) )
LazyColumnWithScrollBar( @Composable
Modifier.align(Alignment.BottomCenter), fun ChatViewListItem(i: Int, range: IntRange?, showAvatar: Boolean, cItem: ChatItem, prevItem: ChatItem?, nextItem: ChatItem?) {
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
additionalBarOffset = composeViewHeight
) {
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
val itemScope = rememberCoroutineScope()
CompositionLocalProvider( CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely. // Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) { ) {
val itemScope = rememberCoroutineScope()
val provider = { val provider = {
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed -> providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
itemScope.launch { itemScope.launch {
@@ -1021,246 +1119,293 @@ fun BoxScope.ChatItemsList(
} }
} }
val revealed = remember { mutableStateOf(false) } val revealed = remember { mutableStateOf(revealedItems.value.contains(cItem.id)) }
KeyChangeEffect(revealed.value) {
val revealIds = if (range == null) setOf(cItem.id) else reversedChatItems.value.subList(range.first, range.last + 1).map { it.id }.toSet()
if (revealIds.isNotEmpty()) {
if (revealed.value) {
revealedItems.value = revealedItems.value.toMutableSet().apply { addAll(revealIds) }
} else {
revealedItems.value = revealedItems.value.toMutableSet().apply { removeAll(revealIds) }
}
}
}
@Composable
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) {
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
if (it == DismissValue.DismissedToStart) {
itemScope.launch {
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
}
}
false
}
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val sent = cItem.chatDir.sent
@Composable @Composable
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) { fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
tryOrShowError("${cItem.id}ChatItem", error = { Box(
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) modifier = modifier.padding(
}) { bottom = if (itemSeparation.largeGap) {
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) if (i == 0) {
8.dp
} else {
4.dp
}
} else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp
),
contentAlignment = Alignment.CenterStart
) {
content()
} }
} }
@Composable @Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) { fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { val chatItemTail = remember { appPreferences.chatItemTail.state }
if (it == DismissValue.DismissedToStart) { val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
itemScope.launch { val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
if (composeState.value.editing) { return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp)
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) }
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) Box {
} val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() }
if (chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val member = cItem.chatDir.groupMember
val (prevMember, memCount) =
if (range != null) {
chatModel.getPrevHiddenMember(member, range)
} else {
null to 1
} }
}
}
false
}
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val sent = cItem.chatDir.sent
@Composable if (showMemberImage(member, prevItem) || showAvatar) {
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { Column(
Box( Modifier
modifier = modifier.padding( .padding(top = 8.dp)
bottom = if (itemSeparation.largeGap) { .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
if (i == 0) { .fillMaxWidth()
8.dp .then(swipeableModifier),
} else { verticalArrangement = Arrangement.spacedBy(4.dp),
4.dp horizontalAlignment = Alignment.Start
} ) {
} else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp @Composable
), fun MemberNameAndRole() {
contentAlignment = Alignment.CenterStart Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) {
) { Text(
content() memberNames(member, prevMember, memCount),
} Modifier
} .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
.weight(1f, false),
fontSize = 13.5.sp,
color = MaterialTheme.colors.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
val chatItemTail = remember { appPreferences.chatItemTail.state }
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
@Composable
fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp {
val chatItemTail = remember { appPreferences.chatItemTail.state }
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp)
}
Box {
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() }
if (chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val member = cItem.chatDir.groupMember
val (prevMember, memCount) =
if (range != null) {
chatModel.getPrevHiddenMember(member, range)
} else {
null to 1
}
if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) {
Column(
Modifier
.padding(top = 8.dp)
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.fillMaxWidth()
.then(swipeableModifier),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start
) {
@Composable
fun MemberNameAndRole() {
Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) {
Text( Text(
memberNames(member, prevMember, memCount), member.memberRole.text,
Modifier Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
.weight(1f, false),
fontSize = 13.5.sp, fontSize = 13.5.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colors.secondary, color = MaterialTheme.colors.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1 maxLines = 1
) )
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
val chatItemTail = remember { appPreferences.chatItemTail.state }
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
Text(
member.memberRole.text,
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
fontSize = 13.5.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colors.secondary,
maxLines = 1
)
}
} }
} }
@Composable
fun Item() {
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
}
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
MemberImage(member)
}
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
ChatItemViewShortHand(cItem, itemSeparation, range, false)
}
}
}
}
if (cItem.content.showMemberName) {
DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
MemberNameAndRole()
Item()
}
} else {
Item()
}
} }
} else {
ChatItemBox { @Composable
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { fun Item() {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
}
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
MemberImage(member)
}
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
ChatItemViewShortHand(cItem, itemSeparation, range, false)
}
}
} }
Row( }
Modifier if (cItem.content.showMemberName) {
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) MemberNameAndRole()
.then(swipeableOrSelectionModifier) Item()
) {
ChatItemViewShortHand(cItem, itemSeparation, range)
} }
} else {
Item()
} }
} }
} else { } else {
ChatItemBox { ChatItemBox {
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
} }
Box( Row(
Modifier Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(if (selectionVisible) Modifier else swipeableModifier) .then(swipeableOrSelectionModifier)
) { ) {
ChatItemViewShortHand(cItem, itemSeparation, range) ChatItemViewShortHand(cItem, itemSeparation, range)
} }
} }
} }
} else { // direct message } else {
ChatItemBox { ChatItemBox {
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
} }
Box( Box(
Modifier.padding( Modifier
start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp)
end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false),
)
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) .then(if (selectionVisible) Modifier else swipeableModifier)
) { ) {
ChatItemViewShortHand(cItem, itemSeparation, range) ChatItemViewShortHand(cItem, itemSeparation, range)
} }
} }
} }
if (selectionVisible) { } else { // direct message
Box(Modifier.matchParentSize().clickable { ChatItemBox {
val checked = selectedChatItems.value?.contains(cItem.id) == true AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems) SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
}) }
Box(
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false),
)
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
) {
ChatItemViewShortHand(cItem, itemSeparation, range)
}
} }
} }
} if (selectionVisible) {
Box(Modifier.matchParentSize().clickable {
val (currIndex, nextItem) = chatModel.getNextChatItem(cItem) val checked = selectedChatItems.value?.contains(cItem.id) == true
val ciCategory = cItem.mergeCategory selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
if (ciCategory != null && ciCategory == nextItem?.mergeCategory) { })
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
} else {
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val itemSeparation = getItemSeparation(cItem, nextItem)
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
if (itemSeparation.date != null) {
DateSeparator(itemSeparation.date)
} }
}
}
val range = chatViewItemsRange(currIndex, prevHidden) if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
val reversed = reversedChatItems.value LaunchedEffect(cItem.id) {
if (revealed.value && range != null) { itemScope.launch {
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci -> delay(600)
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1] val itemRange = if (range != null) {
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation) val firstItem = reversedChatItems.value.getOrNull(range.first)
val lastItem = reversedChatItems.value.getOrNull(range.last)
if (lastItem != null && firstItem != null) {
CC.ItemRange(lastItem.id, firstItem.id)
} else {
null
}
} else {
CC.ItemRange(cItem.id, cItem.id)
} }
} else { if (itemRange != null) {
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation) markRead(itemRange, null)
}
if (i == reversed.lastIndex) {
DateSeparator(cItem.meta.itemTs)
}
}
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
LaunchedEffect(cItem.id) {
itemScope.launch {
delay(600)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
} }
} }
} }
} }
val itemSeparation = getItemSeparation(cItem, nextItem)
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
if (itemSeparation.date != null) {
DateSeparator(itemSeparation.date)
}
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
}
}
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
additionalBarOffset = composeViewHeight
) {
for (area in sections.value) {
for ((sIdx, section) in area.items.withIndex()) {
if (section.revealed) {
itemsIndexed(section.items, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
// index here is just temporary, should be removed at all or put in the section items
val prevItem = area.getPreviousShownItem(sIdx, i)
val nextItem = area.getNextShownItem(sIdx, i)
ChatViewListItem(area.itemPositions[cItem.id] ?: -1, section.originalItemsRange.takeIf { cItem.mergeCategory != null }, section.showAvatar.contains(cItem.id), cItem, prevItem, nextItem, )
}
} else {
val item = section.items.first()
item(key = item.id to item.meta.createdAt.toEpochMilliseconds()) {
// here you make one collapsed item from multiple items (should be already in section items)
val prevItem = area.getPreviousShownItem(sIdx, section.items.lastIndex)
val nextItem = area.getNextShownItem(sIdx, section.items.lastIndex)
ChatViewListItem(area.itemPositions[item.id] ?: -1, section.originalItemsRange, section.showAvatar.contains(item.id), item, prevItem, nextItem)
}
}
}
}
if (reversedChatItems.value.isNotEmpty()) {
item {
DateSeparator(reversedChatItems.value.last().meta.itemTs)
}
}
}
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState) {
preloadItemsEnabled.value = false
scope.launch {
listState.animateScrollToItem(0)
preloadItemsEnabled.value = true
sections.value.dropTemporarySections()
} }
} }
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState)
FloatingDate( FloatingDate(
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter), Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
@@ -1276,13 +1421,13 @@ fun BoxScope.ChatItemsList(
} }
@Composable @Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>) { private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>, enabled: State<Boolean>) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat // Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change // and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) } var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) }
LaunchedEffect(chatId, shouldAutoScroll) { LaunchedEffect(chatId, shouldAutoScroll) {
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) { if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0 && enabled.value) {
scope.launch { listState.scrollToItem(0) } scope.launch { listState.scrollToItem(0) }
} }
// Don't autoscroll next time until it will be needed // Don't autoscroll next time until it will be needed
@@ -1296,7 +1441,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { chatItems.value.lastOrNull()?.id } snapshotFlow { chatItems.value.lastOrNull()?.id }
.distinctUntilChanged() .distinctUntilChanged()
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it && enabled.value }
.collect { .collect {
try { try {
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) { if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) {
@@ -1326,7 +1471,8 @@ fun BoxScope.FloatingButtons(
chatInfo: ChatInfo, chatInfo: ChatInfo,
searchValue: State<String>, searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
listState: LazyListState listState: LazyListState,
scrollToLatestItem: () -> Unit
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } } val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
@@ -1348,9 +1494,7 @@ fun BoxScope.FloatingButtons(
showBottomButtonWithCounter, showBottomButtonWithCounter,
showBottomButtonWithArrow, showBottomButtonWithArrow,
composeViewHeight, composeViewHeight,
onClickArrowDown = { onClickArrowDown = scrollToLatestItem,
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = { onClickCounter = {
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt() val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) } scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
@@ -1397,12 +1541,35 @@ fun PreloadItems(
chatId: String, chatId: String,
listState: LazyListState, listState: LazyListState,
remaining: Int = 10, remaining: Int = 10,
onLoadMore: (ChatId) -> Unit, enabled: State<Boolean>,
boundaries: State<List<ChatSectionAreaBoundary>>,
onLoadMore: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
) { ) {
// Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection // Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection
val allowLoad = remember { mutableStateOf(false) } val allowLoad = remember { mutableStateOf(false) }
val chatId = rememberUpdatedState(chatId) val chatId = rememberUpdatedState(chatId)
val onLoadMore = rememberUpdatedState(onLoadMore) val onLoadMore = rememberUpdatedState(onLoadMore)
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentScrollOffset = listState.firstVisibleItemScrollOffset
val threshold = 25
scrollDirection = when {
currentIndex > previousIndex -> ScrollDirection.Up
currentIndex < previousIndex -> ScrollDirection.Down
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Up
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Down
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
else -> scrollDirection
}
previousIndex = currentIndex
previousScrollOffset = currentScrollOffset
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { chatId.value } snapshotFlow { chatId.value }
.filterNotNull() .filterNotNull()
@@ -1412,19 +1579,41 @@ fun PreloadItems(
allowLoad.value = true allowLoad.value = true
} }
} }
KeyChangeEffect(allowLoad.value) { KeyChangeEffect(allowLoad.value, enabled.value) {
snapshotFlow { snapshotFlow {
val lInfo = listState.layoutInfo val lInfo = listState.layoutInfo
val totalItemsNumber = lInfo.totalItemsCount val totalItemsNumber = lInfo.totalItemsCount
val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) val section = if (scrollDirection == ScrollDirection.Up) {
totalItemsNumber + ChatPagination.PRELOAD_COUNT boundaries.value.find { lastVisibleItemIndex in it.minIndex..it.maxIndex }
else } else if (scrollDirection == ScrollDirection.Down) {
0 boundaries.value.find { listState.firstVisibleItemIndex in it.minIndex..it.maxIndex }
} else {
null
}
val request = if (allowLoad.value && section != null && enabled.value) {
val itemIdx = when {
scrollDirection == ScrollDirection.Up && lastVisibleItemIndex > (section.maxIndex - remaining) -> {
chatModel.chatItems.size - 1 - section.maxIndex
}
scrollDirection == ScrollDirection.Down && section.area != ChatSectionArea.Bottom && listState.firstVisibleItemIndex < (section.minIndex + remaining) && totalItemsNumber > remaining -> {
chatModel.chatItems.size - 1 - section.minIndex
}
else -> null
}
val itemId = itemIdx?.let { chatModel.chatItems.value.getOrNull(it)?.id }
itemId?.let { Triple(it, itemIdx, section.area) }
} else {
null
}
request
} }
.filter { it > 0 } .distinctUntilChanged()
.filterNotNull()
.collect { .collect {
onLoadMore.value(chatId.value) onLoadMore.value(chatId.value, scrollDirection, it)
} }
} }
} }
@@ -2102,7 +2291,7 @@ fun PreviewChatLayout() {
back = {}, back = {},
info = {}, info = {},
showMemberInfo = { _, _ -> }, showMemberInfo = { _, _ -> },
loadPrevMessages = {}, loadMessages = { _, _, _ -> },
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = { _ -> }, deleteMessages = { _ -> },
receiveFile = { _ -> }, receiveFile = { _ -> },
@@ -2174,7 +2363,7 @@ fun PreviewGroupChatLayout() {
back = {}, back = {},
info = {}, info = {},
showMemberInfo = { _, _ -> }, showMemberInfo = { _, _ -> },
loadPrevMessages = {}, loadMessages = { _, _, _ -> },
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = {}, deleteMessages = {},
receiveFile = { _ -> }, receiveFile = { _ -> },
@@ -70,8 +70,9 @@ fun GroupMemberInfoView(
getContactChat = { chatModel.getContactChat(it) }, getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { openDirectChat = {
withBGApi { withBGApi {
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) val r = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (c != null) { if (r != null) {
val (c) = r
withChats { withChats {
if (chatModel.getContactChat(it) == null) { if (chatModel.getContactChat(it) == null) {
addChat(c) addChat(c)
@@ -129,7 +129,13 @@ fun FramedItemView(
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onLongClick = { showMenu.value = true }, onLongClick = { showMenu.value = true },
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) } onClick = {
if (qi.itemId == null) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_get_chat_item_not_found_title), generalGetString(MR.strings.failed_to_get_chat_item_not_found_description))
} else {
scrollToItem(qi.itemId)
}
}
) )
.onRightClick { showMenu.value = true } .onRightClick { showMenu.value = true }
) { ) {
@@ -1,7 +1,6 @@
package chat.simplex.common.views.chatlist package chat.simplex.common.views.chatlist
import SectionItemView import SectionItemView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -32,6 +31,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.* import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@Composable @Composable
@@ -204,43 +204,56 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) {
} }
suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope { suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId) val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (chat != null && isActive) { if (chat != null && isActive) {
openLoadedChat(chat, chatModel) openLoadedChat(chat.first, chatModel, chat.second)
} }
} }
suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope { suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId) val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (chat != null && isActive) { if (chat != null && isActive) {
openLoadedChat(chat, chatModel) openLoadedChat(chat.first, chatModel, chat.second)
} }
} }
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope { suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (chat != null && isActive) { if (chat != null && isActive) {
openLoadedChat(chat, chatModel) openLoadedChat(chat.first, chatModel, chat.second)
} }
} }
fun openLoadedChat(chat: Chat, chatModel: ChatModel) { fun openLoadedChat(chat: Chat, chatModel: ChatModel, landingSection: ChatLandingSection = ChatLandingSection.Latest) {
chatModel.chatItemStatuses.clear() chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(chat.chatItems) chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id chatModel.chatId.value = chat.chatInfo.id
chatModel.chatItemsSectionArea.clear()
chatModel.chatItems.value.associateTo(chatModel.chatItemsSectionArea) { it.id to landingSectionToArea(landingSection) }
} }
suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { suspend fun apiLoadMessages(
val chatInfo = ch.chatInfo rhId: Long?,
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) chatInfo: ChatInfo,
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return chatModel: ChatModel,
itemId: Long,
search: String,
chatSectionLoader: ChatSectionLoader,
pagination: ChatPagination = ChatPagination.Before(itemId, ChatPagination.PRELOAD_COUNT)
) {
val (chat) = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
if (chatModel.chatId.value != chat.id) return if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.addAll(0, chat.chatItems) withContext(Dispatchers.Main) {
val itemsToAdd = chatSectionLoader.prepareItems(chat.chatItems)
if (itemsToAdd.isNotEmpty()) {
chatModel.chatItems.addAll(chatSectionLoader.position, itemsToAdd)
}
}
} }
suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
val chatInfo = ch.chatInfo val chatInfo = ch.chatInfo
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return val (chat) = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
if (chatModel.chatId.value != chat.id) return if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.replaceAll(chat.chatItems) chatModel.chatItems.replaceAll(chat.chatItems)
} }
@@ -105,7 +105,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view // This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } runAtomically { toRemove.removeAll { elem -> modalViews.removeAt(elem); true } }
} }
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
// to prevent unneeded animation on different situations // to prevent unneeded animation on different situations
@@ -184,7 +184,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
} }
// This is needed because if we delete from modalViews immediately on request, animation will be bad // This is needed because if we delete from modalViews immediately on request, animation will be bad
if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } runAtomically { toRemove.removeAll { elem -> modalViews.removeAt(elem); true } }
} }
} }
} }
@@ -101,6 +101,8 @@
<string name="error_loading_xftp_servers">Error loading XFTP servers</string> <string name="error_loading_xftp_servers">Error loading XFTP servers</string>
<string name="error_setting_network_config">Error updating network configuration</string> <string name="error_setting_network_config">Error updating network configuration</string>
<string name="failed_to_parse_chat_title">Failed to load chat</string> <string name="failed_to_parse_chat_title">Failed to load chat</string>
<string name="failed_to_get_chat_item_not_found_title">Message no longer available</string>
<string name="failed_to_get_chat_item_not_found_description">The quoted message you are trying to access has been deleted.</string>
<string name="failed_to_parse_chats_title">Failed to load chats</string> <string name="failed_to_parse_chats_title">Failed to load chats</string>
<string name="contact_developers">Please update the app and contact developers.</string> <string name="contact_developers">Please update the app and contact developers.</string>
<string name="failed_to_create_user_title">Error creating profile!</string> <string name="failed_to_create_user_title">Error creating profile!</string>
@@ -605,7 +605,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi
listGroups count pending = listGroups count pending =
readTVarIO (groupRegs st) >>= \groups -> do readTVarIO (groupRegs st) >>= \groups -> do
grs <- grs <-
if pending if pending
then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups
else pure groups else pure groups
sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "") sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "")
@@ -643,7 +643,7 @@ getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId)
where where
resp :: ChatResponse -> Maybe Contact resp :: ChatResponse -> Maybe Contact
resp = \case resp = \case
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct
_ -> Nothing _ -> Nothing
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo) getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
+1
View File
@@ -150,6 +150,7 @@ library
Simplex.Chat.Migrations.M20240920_user_order Simplex.Chat.Migrations.M20240920_user_order
Simplex.Chat.Migrations.M20241008_indexes Simplex.Chat.Migrations.M20241008_indexes
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared
+8 -6
View File
@@ -735,14 +735,14 @@ processChatCommand' vr = \case
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
-- TODO optimize queries calculating ChatStats, currently they're disabled -- TODO optimize queries calculating ChatStats, currently they're disabled
CTDirect -> do CTDirect -> do
directChat <- withFastStore (\db -> getDirectChat db vr user cId pagination search) (directChat, section) <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTDirect directChat) pure $ CRApiChat user (AChat SCTDirect directChat) section
CTGroup -> do CTGroup -> do
groupChat <- withFastStore (\db -> getGroupChat db vr user cId pagination search) (groupChat, section) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) pure $ CRApiChat user (AChat SCTGroup groupChat) section
CTLocal -> do CTLocal -> do
localChat <- withFastStore (\db -> getLocalChat db user cId pagination search) (localChat, section) <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) pure $ CRApiChat user (AChat SCTLocal localChat) section
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIGetChatItems pagination search -> withUser $ \user -> do APIGetChatItems pagination search -> withUser $ \user -> do
@@ -8301,6 +8301,8 @@ chatCommandP =
(CPLast <$ "count=" <*> A.decimal) (CPLast <$ "count=" <*> A.decimal)
<|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPAround <$ "around=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPInitial <$ "initial=" <*> A.decimal)
paginationByTimeP = paginationByTimeP =
(PTLast <$ "count=" <*> A.decimal) (PTLast <$ "count=" <*> A.decimal)
<|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal) <|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal)
+10 -1
View File
@@ -572,7 +572,7 @@ data ChatResponse
| CRChatSuspended | CRChatSuspended
| CRApiChats {user :: User, chats :: [AChat]} | CRApiChats {user :: User, chats :: [AChat]}
| CRChats {chats :: [AChat]} | CRChats {chats :: [AChat]}
| CRApiChat {user :: User, chat :: AChat} | CRApiChat {user :: User, chat :: AChat, section :: ChatLandingSection}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId) | CRChatItemId User (Maybe ChatItemId)
@@ -839,8 +839,15 @@ data ChatPagination
= CPLast Int = CPLast Int
| CPAfter ChatItemId Int | CPAfter ChatItemId Int
| CPBefore ChatItemId Int | CPBefore ChatItemId Int
| CPAround ChatItemId Int
| CPInitial Int
deriving (Show) deriving (Show)
data ChatLandingSection
= CLSLatest
| CLSUnread
deriving (Show, Eq)
data PaginationByTime data PaginationByTime
= PTLast Int = PTLast Int
| PTAfter UTCTime Int | PTAfter UTCTime Int
@@ -1591,6 +1598,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CLS") ''ChatLandingSection)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
+6
View File
@@ -239,6 +239,12 @@ chatItemTs (CChatItem _ ci) = chatItemTs' ci
chatItemTs' :: ChatItem c d -> UTCTime chatItemTs' :: ChatItem c d -> UTCTime
chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs
chatItemCreatedAt :: CChatItem c -> UTCTime
chatItemCreatedAt (CChatItem _ ci) = chatItemCreatedAt' ci
chatItemCreatedAt' :: ChatItem c d -> UTCTime
chatItemCreatedAt' ChatItem {meta = CIMeta {createdAt}} = createdAt
chatItemTimed :: ChatItem c d -> Maybe CITimed chatItemTimed :: ChatItem c d -> Maybe CITimed
chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed
@@ -0,0 +1,34 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241023_chat_item_autoincrement_id :: Query
m20241023_chat_item_autoincrement_id =
[sql|
INSERT INTO sqlite_sequence (name, seq)
SELECT 'chat_items', MAX(ROWID) FROM chat_items;
PRAGMA writable_schema=1;
UPDATE sqlite_master SET sql = replace(sql, 'INTEGER PRIMARY KEY', 'INTEGER PRIMARY KEY AUTOINCREMENT')
WHERE name = 'chat_items' AND type = 'table';
PRAGMA writable_schema=0;
|]
down_m20241023_chat_item_autoincrement_id :: Query
down_m20241023_chat_item_autoincrement_id =
[sql|
DELETE FROM sqlite_sequence WHERE name = 'chat_items';
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'INTEGER PRIMARY KEY AUTOINCREMENT', 'INTEGER PRIMARY KEY')
WHERE name = 'chat_items' AND type = 'table';
PRAGMA writable_schema=0;
|]
+2 -2
View File
@@ -360,7 +360,7 @@ CREATE TABLE pending_group_messages(
updated_at TEXT NOT NULL DEFAULT(datetime('now')) updated_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE chat_items( CREATE TABLE chat_items(
chat_item_id INTEGER PRIMARY KEY, chat_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE,
@@ -399,6 +399,7 @@ CREATE TABLE chat_items(
fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL,
via_proxy INTEGER via_proxy INTEGER
); );
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE chat_item_messages( CREATE TABLE chat_item_messages(
chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE,
message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE,
@@ -429,7 +430,6 @@ CREATE TABLE commands(
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')) updated_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE settings( CREATE TABLE settings(
settings_id INTEGER PRIMARY KEY, settings_id INTEGER PRIMARY KEY,
chat_item_ttl INTEGER, chat_item_ttl INTEGER,
+328 -162
View File
@@ -138,7 +138,7 @@ import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) import Simplex.Chat.Controller (ChatLandingSection (CLSLatest, CLSUnread), ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
import Simplex.Chat.Markdown import Simplex.Chat.Markdown
import Simplex.Chat.Messages import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent
@@ -947,37 +947,41 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of
aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats
in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, ChatLandingSection)
getDirectChat db vr user contactId pagination search_ = do getDirectChat db vr user contactId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
ct <- getContact db vr user contactId ct <- getContact db vr user contactId
liftIO $ case pagination of case pagination of
CPLast count -> getDirectChatLast_ db user ct count search CPLast count -> liftIO $ (,CLSLatest) <$> getDirectChatLast_ db user ct count search
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search CPAfter afterId count -> (,CLSLatest) <$> getDirectChatAfter_ db user ct afterId count search
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search CPBefore beforeId count -> (,CLSLatest) <$> getDirectChatBefore_ db user ct beforeId count search
CPAround aroundId count -> (,CLSLatest) <$> getDirectChatAround_ db user ct aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getDirectChatInitial_ db user ct count
-- the last items in reverse order (the last item in the conversation is the first in the returned list) -- the last items in reverse order (the last item in the conversation is the first in the returned list)
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do getDirectChatLast_ db user ct count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsLast_ chatItemIds <- getDirectChatItemIdsLast_ db user ct count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemIdsLast_ :: IO [ChatItemId] getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId]
getDirectChatItemIdsLast_ = getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, count) (userId, contactId, search, count)
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect) safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId = safeGetDirectItem db user ct currentTs itemId =
@@ -1021,82 +1025,129 @@ getDirectChatItemLast db user@User {userId} contactId = do
(userId, contactId) (userId, contactId)
getDirectChatItem db user contactId chatItemId getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsAfter_ afterChatItem <- getDirectChatItem db user contactId afterChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) chatItems stats pure $ Chat (DirectChat ct) chatItems stats
where
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
getDirectChatItemIdsAfter_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id > ?
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, contactId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db user ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemsIdsBefore_ beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
getDirectChatItemsIdsBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at < ? OR (created_at = ? AND chat_item_id < ?))
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getDirectChatItem db user contactId aroundItemId
beforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetDirectItem db user ct currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (DirectChat ct) chatItems stats
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect, ChatLandingSection)
getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
firstUnreadItemId_ <- liftIO getDirectChatMinUnreadItemId_
case firstUnreadItemId_ of
Just firstUnreadItemId -> do
chat <- getDirectChatAround_ db user ct firstUnreadItemId count ""
lastItemId <- liftIO $ getDirectChatItemIdsLast_ db user ct 1 ""
pure (chat, landingSection chat lastItemId)
Nothing -> liftIO $ (,CLSLatest) <$> getDirectChatLast_ db user ct count ""
where where
getDirectChatItemsIdsBefore_ :: IO [ChatItemId] getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getDirectChatItemsIdsBefore_ = getDirectChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND contact_id = ? AND item_status = ?
AND chat_item_id < ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, contactId, search, beforeChatItemId, count) (userId, contactId, CISRcvNew)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) landingSection :: Chat c -> [ChatItemId] -> ChatLandingSection
landingSection Chat {chatItems} [lastItemId] = do
let lastItemIdInChat = foldr (\ci acc -> acc || cchatItemId ci == lastItemId) False chatItems
if lastItemIdInChat then CLSLatest else CLSUnread
landingSection _ _ = CLSUnread
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, ChatLandingSection)
getGroupChat db vr user groupId pagination search_ = do getGroupChat db vr user groupId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
g <- getGroupInfo db vr user groupId g <- getGroupInfo db vr user groupId
case pagination of case pagination of
CPLast count -> liftIO $ getGroupChatLast_ db user g count search CPLast count -> liftIO $ (,CLSLatest) <$> getGroupChatLast_ db user g count search
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPAfter afterId count -> (,CLSLatest) <$> getGroupChatAfter_ db user g afterId count search
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search CPBefore beforeId count -> (,CLSLatest) <$> getGroupChatBefore_ db user g beforeId count search
CPAround aroundId count -> (,CLSLatest) <$> getGroupChatAround_ db user g aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getGroupChatInitial_ db user g count
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do getGroupChatLast_ db user g count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getGroupChatItemIdsLast_ chatItemIds <- getGroupChatItemIdsLast_ db user g count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId]
getGroupChatItemIdsLast_ = getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY item_ts DESC, chat_item_id DESC ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, count) (userId, groupId, search, count)
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup) safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId = safeGetGroupItem db user g currentTs itemId =
@@ -1141,83 +1192,122 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
getGroupChatItem db user groupId chatItemId getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId count search = do getGroupChatAfter_ db user g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ db user g afterChatItemId count search (chatItemTs afterChatItem)
currentTs <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) chatItems stats pure $ Chat (GroupChat g) chatItems stats
where
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsAfter_ afterChatItemTs = getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
ORDER BY item_ts ASC, chat_item_id ASC ORDER BY item_ts ASC, chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count) (userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId count search = do getGroupChatBefore_ db user g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ db user g beforeChatItemId count search (chatItemTs beforeChatItem)
currentTs <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
getGroupChatItemIdsBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId count search beforeChatItemTs =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getGroupChatItem db user groupId aroundItemId
beforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g aroundItemId fetchCountBefore search (chatItemTs middleChatItem)
afterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g aroundItemId fetchCountAfter search (chatItemTs middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetGroupItem db user g currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (GroupChat g) chatItems stats
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, ChatLandingSection)
getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
firstUnreadItemId_ <- liftIO getGroupChatMinUnreadItemId_
case firstUnreadItemId_ of
Just firstUnreadItemId -> do
chat <- getGroupChatAround_ db user g firstUnreadItemId count ""
lastItemId <- liftIO $ getGroupChatItemIdsLast_ db user g 1 ""
pure (chat, landingSection chat lastItemId)
Nothing -> liftIO $ (,CLSLatest) <$> getGroupChatLast_ db user g count ""
where where
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getGroupChatItemIdsBefore_ beforeChatItemTs = getGroupChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_status = ?
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count) (userId, groupId, CISRcvNew)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, ChatLandingSection)
getLocalChat db user folderId pagination search_ = do getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId nf <- getNoteFolder db user folderId
liftIO $ case pagination of case pagination of
CPLast count -> getLocalChatLast_ db user nf count search CPLast count -> liftIO $ (,CLSLatest) <$> getLocalChatLast_ db user nf count search
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search CPAfter afterId count -> (,CLSLatest) <$> getLocalChatAfter_ db user nf afterId count search
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search CPBefore beforeId count -> (,CLSLatest) <$> getLocalChatBefore_ db user nf beforeId count search
CPAround aroundId count -> (,CLSLatest) <$> getLocalChatAround_ db user nf aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getLocalChatInitial_ db user nf count
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do getLocalChatLast_ db user nf count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsLast_ chatItemIds <- getLocalChatItemIdsLast_ db user nf count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsLast_ :: IO [ChatItemId] getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId]
getLocalChatItemIdsLast_ = getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, noteFolderId, search, count) (userId, noteFolderId, search, count)
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal) safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId = safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
@@ -1245,51 +1335,88 @@ safeToLocalItem currentTs itemId = \case
file = Nothing file = Nothing
} }
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsAfter_ afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) chatItems stats pure $ Chat (LocalChat nf) chatItems stats
where
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
getLocalChatItemIdsAfter_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id > ?
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, noteFolderId, search, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, noteFolderId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsBefore_ beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
getLocalChatItemIdsBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at < ? OR (created_at = ? AND chat_item_id < ?))
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, noteFolderId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getLocalChatItem db user noteFolderId aroundItemId
beforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetLocalItem db user nf currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (LocalChat nf) chatItems stats
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal, ChatLandingSection)
getLocalChatInitial_ db user@User {userId} nf@NoteFolder {noteFolderId} count = do
firstUnreadItemId_ <- liftIO getLocalChatMinUnreadItemId_
case firstUnreadItemId_ of
Just firstUnreadItemId -> do
chat <- getLocalChatAround_ db user nf firstUnreadItemId count ""
lastItemId <- liftIO $ getLocalChatItemIdsLast_ db user nf 1 ""
pure (chat, landingSection chat lastItemId)
Nothing -> liftIO $ (,CLSLatest) <$> getLocalChatLast_ db user nf count ""
where where
getLocalChatItemIdsBefore_ :: IO [ChatItemId] getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getLocalChatItemIdsBefore_ = getLocalChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
AND chat_item_id < ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, noteFolderId, search, beforeChatItemId, count) (userId, noteFolderId, CISRcvNew)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case toChatItemRef = \case
@@ -1581,6 +1708,13 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
CPLast count -> liftIO $ getAllChatItemsLast_ count CPLast count -> liftIO $ getAllChatItemsLast_ count
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId
CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
firstUnreadItemId <- liftIO getFirstUnreadItemId_
case firstUnreadItemId of
Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId
Nothing -> liftIO $ getAllChatItemsLast_ count
mapM (uncurry (getAChatItem db vr user)) itemRefs mapM (uncurry (getAChatItem db vr user)) itemRefs
where where
search = fromMaybe "" search_ search = fromMaybe "" search_
@@ -1624,6 +1758,31 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
LIMIT ? LIMIT ?
|] |]
(userId, search, beforeTs, beforeTs, beforeId, count) (userId, search, beforeTs, beforeTs, beforeId, count)
getChatItem chatId =
DB.query
db
[sql|
SELECT chat_item_id, contact_id, group_id, note_folder_id
FROM chat_items
WHERE chat_item_id = ?
|]
(Only chatId)
getAllChatItemsAround_ aroundId count aroundTs = do
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
itemsBefore <- getAllChatItemsBefore_ aroundId fetchCountBefore aroundTs
item <- getChatItem aroundId
itemsAfter <- getAllChatItemsAfter_ aroundId fetchCountAfter aroundTs
pure $ itemsBefore <> item <> itemsAfter
getFirstUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND item_status = ?
|]
(userId, CISRcvNew)
getChatItemIdsByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO [ChatItemId] getChatItemIdsByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO [ChatItemId]
getChatItemIdsByAgentMsgId db connId msgId = getChatItemIdsByAgentMsgId db connId msgId =
@@ -2650,3 +2809,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do
LIMIT ? LIMIT ?
|] |]
(userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) (userId, groupId, rcvMsgContentTag, sndMsgContentTag, count)
divideFetchCountAround_ :: Int -> (Int, Int)
divideFetchCountAround_ count =
let fetchCountEachSide = count `div` 2
fetchCountBefore = fetchCountEachSide + if count `mod` 2 /= 0 then 1 else 0
fetchCountAfter = fetchCountEachSide
in (fetchCountBefore, fetchCountAfter)
+3 -1
View File
@@ -114,6 +114,7 @@ import Simplex.Chat.Migrations.M20240827_calls_uuid
import Simplex.Chat.Migrations.M20240920_user_order import Simplex.Chat.Migrations.M20240920_user_order
import Simplex.Chat.Migrations.M20241008_indexes import Simplex.Chat.Migrations.M20241008_indexes
import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@@ -227,7 +228,8 @@ schemaMigrations =
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid), ("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid),
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order), ("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes), ("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes),
("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id) ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id),
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date
+1 -1
View File
@@ -93,7 +93,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChatSuspended -> ["chat suspended"] CRChatSuspended -> ["chat suspended"]
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
CRChats chats -> viewChats ts tz chats CRChats chats -> viewChats ts tz chats
CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiChat u chat lsec -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] <> (["newer messages available" | lsec == CLSUnread])
CRApiParsedMarkdown ft -> [viewJSON ft] CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
+15 -8
View File
@@ -210,6 +210,7 @@ testAddContact = versionTestMatrix2 runTestAddContact
-- pagination -- pagination
alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")]) alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")])
alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")]) alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")])
alice #$> ("/_get chat @2 around=" <> itemId 2 <> " count=3", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")])
-- search -- search
alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")]) alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")])
-- read messages -- read messages
@@ -791,7 +792,7 @@ testDirectMessageDelete =
alice @@@ [("@bob", lastChatFeature)] alice @@@ [("@bob", lastChatFeature)]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures) alice #$> ("/_get chat @2 count=100", chat, chatFeatures)
-- alice: msg id 1 -- alice: msg id 3
bob ##> ("/_update item @2 " <> itemId 2 <> " text hey alice") bob ##> ("/_update item @2 " <> itemId 2 <> " text hey alice")
bob <# "@alice [edited] > hello 🙂" bob <# "@alice [edited] > hello 🙂"
bob <## " hey alice" bob <## " hey alice"
@@ -806,12 +807,12 @@ testDirectMessageDelete =
alice @@@ [("@bob", "hey alice [marked deleted]")] alice @@@ [("@bob", "hey alice [marked deleted]")]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hey alice [marked deleted]")]) alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hey alice [marked deleted]")])
-- alice: deletes msg id 1 that was broadcast deleted by bob -- alice: deletes msg id 3 that was broadcast deleted by bob
alice #$> ("/_delete item @2 " <> itemId 1 <> " internal", id, "message deleted") alice #$> ("/_delete item @2 " <> itemId 3 <> " internal", id, "message deleted")
alice @@@ [("@bob", lastChatFeature)] alice @@@ [("@bob", lastChatFeature)]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures) alice #$> ("/_get chat @2 count=100", chat, chatFeatures)
-- alice: msg id 1, bob: msg id 3 (quoting message alice deleted locally) -- alice: msg id 4, bob: msg id 3 (quoting message alice deleted locally)
bob `send` "> @alice (hello 🙂) do you receive my messages?" bob `send` "> @alice (hello 🙂) do you receive my messages?"
bob <# "@alice > hello 🙂" bob <# "@alice > hello 🙂"
bob <## " do you receive my messages?" bob <## " do you receive my messages?"
@@ -819,14 +820,14 @@ testDirectMessageDelete =
alice <## " do you receive my messages?" alice <## " do you receive my messages?"
alice @@@ [("@bob", "do you receive my messages?")] alice @@@ [("@bob", "do you receive my messages?")]
alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "do you receive my messages?"), Just (1, "hello 🙂"))]) alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "do you receive my messages?"), Just (1, "hello 🙂"))])
alice #$> ("/_delete item @2 " <> itemId 1 <> " broadcast", id, "cannot delete this item") alice #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "cannot delete this item")
-- alice: msg id 2, bob: msg id 4 -- alice: msg id 5, bob: msg id 4
bob #> "@alice how are you?" bob #> "@alice how are you?"
alice <# "bob> how are you?" alice <# "bob> how are you?"
-- alice: deletes msg id 2 -- alice: deletes msg id 5
alice #$> ("/_delete item @2 " <> itemId 2 <> " internal", id, "message deleted") alice #$> ("/_delete item @2 " <> itemId 5 <> " internal", id, "message deleted")
-- bob: marks deleted msg id 4 (that alice deleted locally) -- bob: marks deleted msg id 4 (that alice deleted locally)
bob #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "message marked deleted") bob #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "message marked deleted")
@@ -2340,6 +2341,12 @@ testUserPrivacy =
"bob> Voice messages: enabled", "bob> Voice messages: enabled",
"bob> Audio/video calls: enabled" "bob> Audio/video calls: enabled"
] ]
alice ##> "/_get items around=11 count=3"
alice
<##? [ "bob> Message reactions: enabled",
"bob> Voice messages: enabled",
"bob> Audio/video calls: enabled"
]
alice ##> "/_get items after=12 count=10" alice ##> "/_get items after=12 count=10"
alice alice
<##? [ "@bob hello", <##? [ "@bob hello",
+1
View File
@@ -344,6 +344,7 @@ testGroupShared alice bob cath checkMessages directConnections = do
-- so we take into account group event items as well as sent group invitations in direct chats -- so we take into account group event items as well as sent group invitations in direct chats
alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")])
alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")])
alice #$> ("/_get chat #1 around=" <> msgItem2 <> " count=6", chat, [(0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) -- expecting only 4 items since there is only 1 item after
alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")])
bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")]
bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")])
+5 -1
View File
@@ -51,7 +51,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice ##> "/chats" alice ##> "/chats"
alice /* "ahoy!" alice /* "ahoy!"
alice ##> "/_update item *1 1 text Greetings." alice ##> "/_update item *1 2 text Greetings."
alice ##> "/tail *" alice ##> "/tail *"
alice <# "* Greetings." alice <# "* Greetings."
@@ -102,6 +102,10 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")]) alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")])
alice #$> ("/_get chat *1 around=2 count=1", chat, [(1, "memento mori")])
alice #$> ("/_get chat *1 around=2 count=3", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock")])
alice #$> ("/_get chat *1 around=3 count=10", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 around=4 count=3", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")]) alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")])
+3 -1
View File
@@ -102,7 +102,9 @@ skipComparisonForDownMigrations =
-- table and indexes move down to the end of the file -- table and indexes move down to the end of the file
"20231215_recreate_msg_deliveries", "20231215_recreate_msg_deliveries",
-- on down migration idx_msg_deliveries_agent_ack_cmd_id index moves down to the end of the file -- on down migration idx_msg_deliveries_agent_ack_cmd_id index moves down to the end of the file
"20240313_drop_agent_ack_cmd_id" "20240313_drop_agent_ack_cmd_id",
-- on down migration chat_item_autoincrement_id makes sequence table creation move down on the file
"20241023_chat_item_autoincrement_id"
] ]
getSchema :: FilePath -> FilePath -> IO String getSchema :: FilePath -> FilePath -> IO String