mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a22706d01 | |||
| bfac8bd58a | |||
| 34c10fc12f | |||
| 22fc2e5151 | |||
| 2396531414 | |||
| a3906cf88f | |||
| d03fc53151 | |||
| 69138dd99f | |||
| f9d01f6f8e | |||
| b90f7a932f | |||
| 40db6686c1 | |||
| 21f9c7fd50 | |||
| d15560a29f | |||
| f197c4174c | |||
| 99719088e5 | |||
| 01611fd719 | |||
| 0ce7d16a59 | |||
| bf20b73893 | |||
| c9ae77e9f3 | |||
| d875fc99f4 | |||
| 1c4c48f168 | |||
| 122d5948af | |||
| fd5e5d295f | |||
| 3a2e59a6e8 | |||
| 494724de01 | |||
| 089e5f6ec1 | |||
| 74c6a425b3 | |||
| 0698f4acfc | |||
| 779a91f5cb | |||
| 20c4c02f63 | |||
| d2a473c908 | |||
| 01d0c4ef96 | |||
| bab2341406 | |||
| c98e8e774f | |||
| cf32d1d950 | |||
| 065cc170da | |||
| a84b56d39d | |||
| 937e66b3b8 | |||
| 638b237b8c | |||
| 37a5c353ea | |||
| 31eca1687c | |||
| 7e9ab7cd85 | |||
| 8ff2f66bb9 | |||
| e2de454426 | |||
| f87a4a43c9 | |||
| 83ce1f5808 | |||
| 4bbee43602 | |||
| 0ee2769e15 | |||
| 82e6b2eefc | |||
| 97894c7f53 | |||
| ff42ca69de | |||
| 5ccf00f70e | |||
| 22be251862 | |||
| 84bd23a99d | |||
| 9737f6baa6 | |||
| 39e3a8cb70 | |||
| 649561fbc9 | |||
| c681c89657 | |||
| 7ad0a2dd87 | |||
| 71d891acc2 | |||
| 7faa5b4f6a | |||
| c93ed87228 | |||
| 2b170830df | |||
| 19755e359d | |||
| 53c77d310a | |||
| 796d3916bd | |||
| b93abb9555 | |||
| c31560238a | |||
| 2b7ad80dfd | |||
| d8f5b6e813 | |||
| b6359559c1 | |||
| 5826d14866 | |||
| a9ca467a80 | |||
| 185e307e70 | |||
| f738d2731c | |||
| ea61fd7e2b |
+11
@@ -11,6 +11,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
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.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrationToDeviceState
|
||||
@@ -65,6 +66,8 @@ object ChatModel {
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
|
||||
// chatItemId, SectionArea
|
||||
val chatItemsSectionArea = mutableMapOf<Long, ChatSectionArea>()
|
||||
// rhId, chatId
|
||||
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
|
||||
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
|
||||
@@ -329,6 +332,7 @@ object ChatModel {
|
||||
if (chatId.value == cInfo.id) {
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.value.none { it.id == cItem.id }) {
|
||||
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
|
||||
} else {
|
||||
@@ -377,6 +381,7 @@ object ChatModel {
|
||||
} else {
|
||||
cItem
|
||||
}
|
||||
chatItemsSectionArea[ci.id] = ChatSectionArea.Bottom
|
||||
chatItems.add(ci)
|
||||
true
|
||||
}
|
||||
@@ -608,6 +613,7 @@ object ChatModel {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatItems.add(cItem)
|
||||
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
@@ -615,6 +621,7 @@ object ChatModel {
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
|
||||
}
|
||||
|
||||
+24
-7
@@ -865,11 +865,15 @@ object ChatController {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Pair<Chat, ChatLandingSection>? {
|
||||
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)
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
|
||||
if (r is CR.ApiChat) return if (rh == null) Pair(r.chat, r.section) else Pair(r.chat.copy(remoteHostId = rh), r.section)
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3468,11 +3472,15 @@ sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Before(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Around(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Initial(val count: Int): ChatPagination()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Last -> "count=${this.count}"
|
||||
is After -> "after=${this.chatItemId} count=${this.count}"
|
||||
is Before -> "before=${this.chatItemId} count=${this.count}"
|
||||
is Around -> "around=${this.chatItemId} count=${this.count}"
|
||||
is Initial -> "initial=${this.count}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -4857,7 +4865,10 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul
|
||||
} else if (type == "apiChat") {
|
||||
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
|
||||
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") {
|
||||
val userObject = resp["user_"]?.jsonObject
|
||||
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
|
||||
@@ -4914,7 +4925,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
||||
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val section: ChatLandingSection): CR()
|
||||
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
|
||||
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
|
||||
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
|
||||
@@ -5264,7 +5275,7 @@ sealed class CR {
|
||||
is ChatRunning -> noDetails()
|
||||
is ChatStopped -> noDetails()
|
||||
is ApiChats -> withUser(user, json.encodeToString(chats))
|
||||
is ApiChat -> withUser(user, json.encodeToString(chat))
|
||||
is ApiChat -> withUser(user, "section: ${json.encodeToString(section)}\n${json.encodeToString(chat)}")
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
|
||||
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
|
||||
@@ -5504,6 +5515,12 @@ sealed class GroupLinkPlan {
|
||||
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ChatLandingSection {
|
||||
@SerialName("latest") Latest,
|
||||
@SerialName("unread") Unread,
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
abstract val id: Long
|
||||
abstract val remoteHostId: Long?
|
||||
|
||||
+287
@@ -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)
|
||||
}
|
||||
}
|
||||
+428
-239
@@ -47,8 +47,7 @@ import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.*
|
||||
|
||||
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 ->
|
||||
val c = chatModel.getChat(chatId)
|
||||
loadMessages = { chatId, scrollDirection, (itemId, idx, area) ->
|
||||
val c = chatModel.getChat(chatId) ?: return@ChatLayout
|
||||
if (chatModel.chatId.value != chatId) return@ChatLayout
|
||||
val firstId = chatModel.chatItems.value.firstOrNull()?.id
|
||||
if (c != null && firstId != null) {
|
||||
withBGApi {
|
||||
apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
|
||||
withBGApi {
|
||||
when (scrollDirection) {
|
||||
ScrollDirection.Up -> {
|
||||
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,
|
||||
info: () -> Unit,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatId) -> Unit,
|
||||
loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -611,7 +632,7 @@ fun ChatLayout(
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showSearch: MutableState<Boolean>
|
||||
showSearch: MutableState<Boolean>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
|
||||
@@ -649,10 +670,10 @@ fun ChatLayout(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
ChatItemsList(
|
||||
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
|
||||
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
|
||||
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
|
||||
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -922,7 +943,7 @@ fun BoxScope.ChatItemsList(
|
||||
linkMode: SimplexLinkMode,
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatId) -> Unit,
|
||||
loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -943,11 +964,12 @@ fun BoxScope.ChatItemsList(
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean
|
||||
showViaProxy: Boolean,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
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()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
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))
|
||||
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 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 {
|
||||
mutableStateOf(
|
||||
{ itemId: Long ->
|
||||
val index = reversedChatItems.value.indexOfFirst { it.id == itemId }
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) }
|
||||
mutableStateOf({ itemId: Long ->
|
||||
val index = sections.value.chatItemPosition(itemId)
|
||||
preloadItemsEnabled.value = false
|
||||
|
||||
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
|
||||
LaunchedEffect(chatInfo.id) {
|
||||
@@ -993,23 +1100,14 @@ fun BoxScope.ChatItemsList(
|
||||
VideoPlayerHolder.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumnWithScrollBar(
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
|
||||
val itemScope = rememberCoroutineScope()
|
||||
@Composable
|
||||
fun ChatViewListItem(i: Int, range: IntRange?, showAvatar: Boolean, cItem: ChatItem, prevItem: ChatItem?, nextItem: ChatItem?) {
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
val itemScope = rememberCoroutineScope()
|
||||
val provider = {
|
||||
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
|
||||
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
|
||||
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)
|
||||
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
bottom = if (itemSeparation.largeGap) {
|
||||
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
|
||||
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))
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val sent = cItem.chatDir.sent
|
||||
|
||||
@Composable
|
||||
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
bottom = if (itemSeparation.largeGap) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
if (showMemberImage(member, prevItem) || showAvatar) {
|
||||
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(
|
||||
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(
|
||||
memberNames(member, prevMember, memCount),
|
||||
Modifier
|
||||
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
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,
|
||||
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
|
||||
|
||||
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 {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.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)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
if (cItem.content.showMemberName) {
|
||||
DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
|
||||
MemberNameAndRole()
|
||||
Item()
|
||||
}
|
||||
} else {
|
||||
Item()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
Box(
|
||||
Row(
|
||||
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)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
} else {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
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),
|
||||
)
|
||||
Modifier
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectionVisible) {
|
||||
Box(Modifier.matchParentSize().clickable {
|
||||
val checked = selectedChatItems.value?.contains(cItem.id) == true
|
||||
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
|
||||
})
|
||||
} else { // direct message
|
||||
ChatItemBox {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val (currIndex, nextItem) = chatModel.getNextChatItem(cItem)
|
||||
val ciCategory = cItem.mergeCategory
|
||||
if (ciCategory != null && ciCategory == nextItem?.mergeCategory) {
|
||||
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
|
||||
} else {
|
||||
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
|
||||
|
||||
val itemSeparation = getItemSeparation(cItem, nextItem)
|
||||
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
|
||||
|
||||
if (itemSeparation.date != null) {
|
||||
DateSeparator(itemSeparation.date)
|
||||
if (selectionVisible) {
|
||||
Box(Modifier.matchParentSize().clickable {
|
||||
val checked = selectedChatItems.value?.contains(cItem.id) == true
|
||||
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val range = chatViewItemsRange(currIndex, prevHidden)
|
||||
val reversed = reversedChatItems.value
|
||||
if (revealed.value && range != null) {
|
||||
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
|
||||
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1]
|
||||
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
|
||||
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
itemScope.launch {
|
||||
delay(600)
|
||||
val itemRange = if (range != null) {
|
||||
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 {
|
||||
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
|
||||
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)
|
||||
if (itemRange != null) {
|
||||
markRead(itemRange, 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(
|
||||
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
|
||||
@@ -1276,13 +1421,13 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
|
||||
@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()
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) }
|
||||
LaunchedEffect(chatId, shouldAutoScroll) {
|
||||
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) {
|
||||
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0 && enabled.value) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
// Don't autoscroll next time until it will be needed
|
||||
@@ -1296,7 +1441,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatItems.value.lastOrNull()?.id }
|
||||
.distinctUntilChanged()
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it && enabled.value }
|
||||
.collect {
|
||||
try {
|
||||
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) {
|
||||
@@ -1326,7 +1471,8 @@ fun BoxScope.FloatingButtons(
|
||||
chatInfo: ChatInfo,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
listState: LazyListState
|
||||
listState: LazyListState,
|
||||
scrollToLatestItem: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
|
||||
@@ -1348,9 +1494,7 @@ fun BoxScope.FloatingButtons(
|
||||
showBottomButtonWithCounter,
|
||||
showBottomButtonWithArrow,
|
||||
composeViewHeight,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickArrowDown = scrollToLatestItem,
|
||||
onClickCounter = {
|
||||
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
|
||||
@@ -1397,12 +1541,35 @@ fun PreloadItems(
|
||||
chatId: String,
|
||||
listState: LazyListState,
|
||||
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
|
||||
val allowLoad = remember { mutableStateOf(false) }
|
||||
val chatId = rememberUpdatedState(chatId)
|
||||
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) {
|
||||
snapshotFlow { chatId.value }
|
||||
.filterNotNull()
|
||||
@@ -1412,19 +1579,41 @@ fun PreloadItems(
|
||||
allowLoad.value = true
|
||||
}
|
||||
}
|
||||
KeyChangeEffect(allowLoad.value) {
|
||||
KeyChangeEffect(allowLoad.value, enabled.value) {
|
||||
snapshotFlow {
|
||||
val lInfo = listState.layoutInfo
|
||||
val totalItemsNumber = lInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||
totalItemsNumber + ChatPagination.PRELOAD_COUNT
|
||||
else
|
||||
0
|
||||
val section = if (scrollDirection == ScrollDirection.Up) {
|
||||
boundaries.value.find { lastVisibleItemIndex in it.minIndex..it.maxIndex }
|
||||
} else if (scrollDirection == ScrollDirection.Down) {
|
||||
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 {
|
||||
onLoadMore.value(chatId.value)
|
||||
onLoadMore.value(chatId.value, scrollDirection, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2102,7 +2291,7 @@ fun PreviewChatLayout() {
|
||||
back = {},
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadPrevMessages = {},
|
||||
loadMessages = { _, _, _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessages = { _ -> },
|
||||
receiveFile = { _ -> },
|
||||
@@ -2174,7 +2363,7 @@ fun PreviewGroupChatLayout() {
|
||||
back = {},
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadPrevMessages = {},
|
||||
loadMessages = { _, _, _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessages = {},
|
||||
receiveFile = { _ -> },
|
||||
|
||||
+3
-2
@@ -70,8 +70,9 @@ fun GroupMemberInfoView(
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
openDirectChat = {
|
||||
withBGApi {
|
||||
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
val r = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
if (r != null) {
|
||||
val (c) = r
|
||||
withChats {
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
addChat(c)
|
||||
|
||||
+7
-1
@@ -129,7 +129,13 @@ fun FramedItemView(
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
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 }
|
||||
) {
|
||||
|
||||
+27
-14
@@ -1,7 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -32,6 +31,7 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -204,43 +204,56 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) {
|
||||
}
|
||||
|
||||
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) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
openLoadedChat(chat.first, chatModel, chat.second)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
openLoadedChat(chat, chatModel)
|
||||
openLoadedChat(chat.first, chatModel, chat.second)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.chatItems.replaceAll(chat.chatItems)
|
||||
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) {
|
||||
val chatInfo = ch.chatInfo
|
||||
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
suspend fun apiLoadMessages(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
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
|
||||
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) {
|
||||
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
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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.
|
||||
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
|
||||
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)
|
||||
// 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
|
||||
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_setting_network_config">Error updating network configuration</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="contact_developers">Please update the app and contact developers.</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 =
|
||||
readTVarIO (groupRegs st) >>= \groups -> do
|
||||
grs <-
|
||||
if pending
|
||||
if pending
|
||||
then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups
|
||||
else pure groups
|
||||
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
|
||||
resp :: ChatResponse -> Maybe Contact
|
||||
resp = \case
|
||||
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct
|
||||
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct
|
||||
_ -> Nothing
|
||||
|
||||
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
|
||||
|
||||
@@ -150,6 +150,7 @@ library
|
||||
Simplex.Chat.Migrations.M20240920_user_order
|
||||
Simplex.Chat.Migrations.M20241008_indexes
|
||||
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
|
||||
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
|
||||
+8
-6
@@ -735,14 +735,14 @@ processChatCommand' vr = \case
|
||||
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
|
||||
-- TODO optimize queries calculating ChatStats, currently they're disabled
|
||||
CTDirect -> do
|
||||
directChat <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTDirect directChat)
|
||||
(directChat, section) <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTDirect directChat) section
|
||||
CTGroup -> do
|
||||
groupChat <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTGroup groupChat)
|
||||
(groupChat, section) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTGroup groupChat) section
|
||||
CTLocal -> do
|
||||
localChat <- withFastStore (\db -> getLocalChat db user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTLocal localChat)
|
||||
(localChat, section) <- withFastStore (\db -> getLocalChat db user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTLocal localChat) section
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
APIGetChatItems pagination search -> withUser $ \user -> do
|
||||
@@ -8301,6 +8301,8 @@ chatCommandP =
|
||||
(CPLast <$ "count=" <*> A.decimal)
|
||||
<|> (CPAfter <$ "after=" <*> 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 =
|
||||
(PTLast <$ "count=" <*> A.decimal)
|
||||
<|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal)
|
||||
|
||||
@@ -572,7 +572,7 @@ data ChatResponse
|
||||
| CRChatSuspended
|
||||
| CRApiChats {user :: User, 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]}
|
||||
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
|
||||
| CRChatItemId User (Maybe ChatItemId)
|
||||
@@ -839,8 +839,15 @@ data ChatPagination
|
||||
= CPLast Int
|
||||
| CPAfter ChatItemId Int
|
||||
| CPBefore ChatItemId Int
|
||||
| CPAround ChatItemId Int
|
||||
| CPInitial Int
|
||||
deriving (Show)
|
||||
|
||||
data ChatLandingSection
|
||||
= CLSLatest
|
||||
| CLSUnread
|
||||
deriving (Show, Eq)
|
||||
|
||||
data PaginationByTime
|
||||
= PTLast Int
|
||||
| PTAfter UTCTime Int
|
||||
@@ -1591,6 +1598,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "CLS") ''ChatLandingSection)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
|
||||
|
||||
$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
|
||||
|
||||
@@ -239,6 +239,12 @@ chatItemTs (CChatItem _ ci) = chatItemTs' ci
|
||||
chatItemTs' :: ChatItem c d -> UTCTime
|
||||
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 {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;
|
||||
|]
|
||||
@@ -360,7 +360,7 @@ CREATE TABLE pending_group_messages(
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
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,
|
||||
contact_id INTEGER REFERENCES contacts 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,
|
||||
via_proxy INTEGER
|
||||
);
|
||||
CREATE TABLE sqlite_sequence(name,seq);
|
||||
CREATE TABLE chat_item_messages(
|
||||
chat_item_id INTEGER NOT NULL REFERENCES chat_items 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')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE sqlite_sequence(name,seq);
|
||||
CREATE TABLE settings(
|
||||
settings_id INTEGER PRIMARY KEY,
|
||||
chat_item_ttl INTEGER,
|
||||
|
||||
+328
-162
@@ -138,7 +138,7 @@ import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
||||
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.Messages
|
||||
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
|
||||
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
|
||||
let search = fromMaybe "" search_
|
||||
ct <- getContact db vr user contactId
|
||||
liftIO $ case pagination of
|
||||
CPLast count -> getDirectChatLast_ db user ct count search
|
||||
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
|
||||
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getDirectChatLast_ db user ct count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getDirectChatAfter_ db user ct afterId 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)
|
||||
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}
|
||||
chatItemIds <- getDirectChatItemIdsLast_
|
||||
chatItemIds <- getDirectChatItemIdsLast_ db user ct count search
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||
where
|
||||
getDirectChatItemIdsLast_ :: IO [ChatItemId]
|
||||
getDirectChatItemIdsLast_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, count)
|
||||
|
||||
getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId]
|
||||
getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, count)
|
||||
|
||||
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
|
||||
safeGetDirectItem db user ct currentTs itemId =
|
||||
@@ -1021,82 +1025,129 @@ getDirectChatItemLast db user@User {userId} contactId = do
|
||||
(userId, contactId)
|
||||
getDirectChatItem db user contactId chatItemId
|
||||
|
||||
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
|
||||
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do
|
||||
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- getDirectChatItemIdsAfter_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
afterChatItem <- getDirectChatItem db user contactId afterChatItemId
|
||||
chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
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)
|
||||
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do
|
||||
getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
|
||||
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}
|
||||
chatItemIds <- getDirectChatItemsIdsBefore_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
|
||||
chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
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
|
||||
getDirectChatItemsIdsBefore_ :: IO [ChatItemId]
|
||||
getDirectChatItemsIdsBefore_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getDirectChatMinUnreadItemId_ =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
SELECT MIN(chat_item_id)
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
AND chat_item_id < ?
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
WHERE user_id = ? AND contact_id = ? AND item_status = ?
|
||||
|]
|
||||
(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
|
||||
let search = fromMaybe "" search_
|
||||
g <- getGroupInfo db vr user groupId
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
|
||||
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
|
||||
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getGroupChatLast_ db user g count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getGroupChatAfter_ db user g afterId 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 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}
|
||||
chatItemIds <- getGroupChatItemIdsLast_
|
||||
chatItemIds <- getGroupChatItemIdsLast_ db user g count search
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
||||
where
|
||||
getGroupChatItemIdsLast_ :: IO [ChatItemId]
|
||||
getGroupChatItemIdsLast_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY item_ts DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, groupId, search, count)
|
||||
|
||||
getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId]
|
||||
getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY item_ts DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, groupId, search, count)
|
||||
|
||||
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
|
||||
safeGetGroupItem db user g currentTs itemId =
|
||||
@@ -1141,83 +1192,122 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
|
||||
getGroupChatItem db user groupId chatItemId
|
||||
|
||||
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}
|
||||
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
|
||||
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
pure $ Chat (GroupChat g) chatItems stats
|
||||
where
|
||||
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
|
||||
getGroupChatItemIdsAfter_ afterChatItemTs =
|
||||
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 ASC, chat_item_id ASC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
|
||||
|
||||
getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
|
||||
getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
|
||||
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 ASC, chat_item_id ASC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
|
||||
|
||||
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}
|
||||
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
|
||||
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
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
|
||||
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
|
||||
getGroupChatItemIdsBefore_ beforeChatItemTs =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getGroupChatMinUnreadItemId_ =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
SELECT MIN(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 ?
|
||||
WHERE user_id = ? AND group_id = ? AND item_status = ?
|
||||
|]
|
||||
(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
|
||||
let search = fromMaybe "" search_
|
||||
nf <- getNoteFolder db user folderId
|
||||
liftIO $ case pagination of
|
||||
CPLast count -> getLocalChatLast_ db user nf count search
|
||||
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
|
||||
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getLocalChatLast_ db user nf count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getLocalChatAfter_ db user nf afterId 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 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}
|
||||
chatItemIds <- getLocalChatItemIdsLast_
|
||||
chatItemIds <- getLocalChatItemIdsLast_ db user nf count search
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
||||
where
|
||||
getLocalChatItemIdsLast_ :: IO [ChatItemId]
|
||||
getLocalChatItemIdsLast_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, noteFolderId, search, count)
|
||||
|
||||
getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId]
|
||||
getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, noteFolderId, search, count)
|
||||
|
||||
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
|
||||
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
|
||||
@@ -1245,51 +1335,88 @@ safeToLocalItem currentTs itemId = \case
|
||||
file = Nothing
|
||||
}
|
||||
|
||||
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
|
||||
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do
|
||||
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- getLocalChatItemIdsAfter_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
|
||||
chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
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)
|
||||
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
|
||||
getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
|
||||
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}
|
||||
chatItemIds <- getLocalChatItemIdsBefore_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
|
||||
chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
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
|
||||
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
|
||||
getLocalChatItemIdsBefore_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getLocalChatMinUnreadItemId_ =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
SELECT MIN(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 DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
|
||||
|]
|
||||
(userId, noteFolderId, search, beforeChatItemId, count)
|
||||
(userId, noteFolderId, CISRcvNew)
|
||||
|
||||
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
|
||||
toChatItemRef = \case
|
||||
@@ -1581,6 +1708,13 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
|
||||
CPLast count -> liftIO $ getAllChatItemsLast_ count
|
||||
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
|
||||
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
|
||||
where
|
||||
search = fromMaybe "" search_
|
||||
@@ -1624,6 +1758,31 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
|
||||
LIMIT ?
|
||||
|]
|
||||
(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 connId msgId =
|
||||
@@ -2650,3 +2809,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do
|
||||
LIMIT ?
|
||||
|]
|
||||
(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)
|
||||
@@ -114,6 +114,7 @@ import Simplex.Chat.Migrations.M20240827_calls_uuid
|
||||
import Simplex.Chat.Migrations.M20240920_user_order
|
||||
import Simplex.Chat.Migrations.M20241008_indexes
|
||||
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 (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -227,7 +228,8 @@ schemaMigrations =
|
||||
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid),
|
||||
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
|
||||
("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
|
||||
|
||||
@@ -93,7 +93,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRChatSuspended -> ["chat suspended"]
|
||||
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON 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]
|
||||
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
|
||||
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
|
||||
|
||||
@@ -210,6 +210,7 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
-- pagination
|
||||
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 around=" <> itemId 2 <> " count=3", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")])
|
||||
-- search
|
||||
alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")])
|
||||
-- read messages
|
||||
@@ -791,7 +792,7 @@ testDirectMessageDelete =
|
||||
alice @@@ [("@bob", lastChatFeature)]
|
||||
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 <# "@alice [edited] > hello 🙂"
|
||||
bob <## " hey alice"
|
||||
@@ -806,12 +807,12 @@ testDirectMessageDelete =
|
||||
alice @@@ [("@bob", "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 #$> ("/_delete item @2 " <> itemId 1 <> " internal", id, "message deleted")
|
||||
-- alice: deletes msg id 3 that was broadcast deleted by bob
|
||||
alice #$> ("/_delete item @2 " <> itemId 3 <> " internal", id, "message deleted")
|
||||
alice @@@ [("@bob", lastChatFeature)]
|
||||
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 <# "@alice > hello 🙂"
|
||||
bob <## " do you receive my messages?"
|
||||
@@ -819,14 +820,14 @@ testDirectMessageDelete =
|
||||
alice <## " 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 #$> ("/_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?"
|
||||
alice <# "bob> how are you?"
|
||||
|
||||
-- alice: deletes msg id 2
|
||||
alice #$> ("/_delete item @2 " <> itemId 2 <> " internal", id, "message deleted")
|
||||
-- alice: deletes msg id 5
|
||||
alice #$> ("/_delete item @2 " <> itemId 5 <> " internal", id, "message deleted")
|
||||
|
||||
-- bob: marks deleted msg id 4 (that alice deleted locally)
|
||||
bob #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "message marked deleted")
|
||||
@@ -2340,6 +2341,12 @@ testUserPrivacy =
|
||||
"bob> Voice messages: 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
|
||||
<##? [ "@bob hello",
|
||||
|
||||
@@ -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
|
||||
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 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")])
|
||||
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")])
|
||||
|
||||
@@ -51,7 +51,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
|
||||
alice ##> "/chats"
|
||||
|
||||
alice /* "ahoy!"
|
||||
alice ##> "/_update item *1 1 text Greetings."
|
||||
alice ##> "/_update item *1 2 text Greetings."
|
||||
alice ##> "/tail *"
|
||||
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=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=2", chat, [(1, "knock-knock"), (1, "who's there?")])
|
||||
alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")])
|
||||
|
||||
+3
-1
@@ -102,7 +102,9 @@ skipComparisonForDownMigrations =
|
||||
-- table and indexes move down to the end of the file
|
||||
"20231215_recreate_msg_deliveries",
|
||||
-- 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
|
||||
|
||||
Reference in New Issue
Block a user