Compare commits

...

47 Commits

Author SHA1 Message Date
Diogo 73ee18a610 wip putIntoGroups refactor 2024-11-11 23:20:13 +00:00
Diogo aa65c197f3 gap -> anchor 2024-11-11 17:30:48 +00:00
Diogo b078d62921 new api behaviour 2024-11-11 15:49:05 +00:00
Diogo 0a0990c14c Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-11 14:59:05 +00:00
Diogo 07a9050af5 core: remove gaps and make sure page size is always the same (#5163)
* remove gaps

* consistent pagination size

* proper fix and around fix too

* optimize

* refactor
2024-11-11 14:47:17 +00:00
Diogo 0b3e1614e3 build 2024-11-10 01:21:49 +00:00
Diogo bc97b262e5 wip 2024-11-10 00:55:42 +00:00
Diogo 1b2d7e93f7 cleanup 2024-11-09 22:00:11 +00:00
Diogo 4847d2222f take delete animations out 2024-11-09 15:51:09 +00:00
Diogo 73d2929af3 try new approach 2024-11-09 15:49:31 +00:00
Diogo 674032b888 fix gap size 2024-11-08 11:15:16 +00:00
Diogo d47898877a fix before cut off when a lot of items come in same page 2024-11-08 10:03:20 +00:00
Diogo 096b9f9613 fix get first unread when deleted or grouped 2024-11-08 09:42:32 +00:00
Diogo 171abeb145 unused code 2024-11-07 23:55:11 +00:00
Diogo fbddf1d34e drop items when scrolling up 2024-11-07 23:51:56 +00:00
Diogo 9974460dcf drop after gap items when > optimal size 2024-11-07 23:21:05 +00:00
Diogo a8e2fd3c97 lash mark read 2024-11-07 23:09:45 +00:00
Diogo de1c0f2bf8 fix long pages landing 2024-11-07 23:00:44 +00:00
Diogo f167b31e85 remove no longer needed logs 2024-11-07 22:36:23 +00:00
Diogo a2f4615895 fix unread bellow and stop fetching on chat open scroll 2024-11-07 22:36:09 +00:00
Diogo 339394213b optimize api calls 2024-11-07 22:06:04 +00:00
Diogo c832f9b290 clean gap on multi jump 2024-11-07 21:42:45 +00:00
Diogo b4342c4037 delay setting destination on long scrolls 2024-11-07 21:22:22 +00:00
Diogo 7376b289ca fix open chat from group when gaps 2024-11-07 21:13:40 +00:00
Diogo f1168a9973 mark us unread fix on chat open 2024-11-07 18:54:48 +00:00
Diogo 13ece144f2 fixed gaps 2024-11-07 16:52:30 +00:00
Diogo 7168fd9094 simpler scroll to unread without crazy animations 2024-11-07 14:42:32 +00:00
Diogo 1720c843b1 Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-07 13:52:21 +00:00
Diogo 0d0f0aa434 core: fix one item missing from latest in initial and wrong check (#5153)
* core: fix one item missing from latest in initial and wrong check

* final fixes and tests

* clearer tests
2024-11-07 12:51:19 +00:00
Diogo 1c697c4a31 fixed scroll to unread when not visible 2024-11-06 16:21:10 +00:00
Diogo 9155a2f02a placeholders looking good and sync page sizes with android 2024-11-06 15:25:10 +00:00
Diogo 9b6365ca88 placeholder adjustments 2024-11-06 14:11:16 +00:00
Diogo 6301acd9ff fix near bottom flag and new messages arriving 2024-11-06 12:54:24 +00:00
Diogo 5eaf563b96 jump to bottom 2024-11-06 11:15:51 +00:00
Diogo f8e69ea6e7 Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-06 10:38:46 +00:00
Diogo 5c36d15b2b first page sometimes blocks fetches 2024-11-06 10:38:03 +00:00
Diogo 77df3cc208 core: fix initial api latest chat items ordering (#5151) 2024-11-06 10:25:55 +00:00
Diogo ff7fcaf7f3 wip 2024-11-06 10:15:57 +00:00
Diogo 547dbc5271 jumps unreads and gaps 2024-11-05 22:33:24 +00:00
Diogo 0e0eeb4a57 open on unread 2024-11-05 15:55:55 +00:00
Diogo f8226554ff initial api integration and types 2024-11-05 15:55:45 +00:00
Diogo 4cf3da05c3 core: initial landing api for chat and gaps (#5104)
* initial work on initial param for loading chat

* support for initial

* controller parse

* fixed sqls

* refactor names

* fix ChatLandingSection serialized type

* total accuracy on landing section

* descriptive view message

* foldr

* refactor to make landingSection reusable

* refactor: use foldr everywhere

* propagate search

* Revert "propagate search"

This reverts commit 01611fd719.

* throw when search is sent for initial

* gap size wip (needs testing)

* final

* remove order by

* remove index

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-11-05 15:52:30 +00:00
Diogo 65c136c7fb Merge branch 'master' into dc/core-pagination 2024-11-04 22:43:57 +00:00
Diogo a84b56d39d Merge branch 'master' into dc/core-pagination 2024-10-31 22:07:59 +00:00
Diogo a9ca467a80 Merge branch 'master' into dc/core-pagination 2024-10-26 22:21:44 +01:00
Diogo f738d2731c core: initial api support for items around a given item (#5092)
* core: initial api support for items around a given item

* implementation and tests for local messages

* pass entities down

* unused

* getAllChatItems implementation and tests

* pagination for getting chat and tests

* remove unused import

* group implementation and tests

* refactor

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

* file name

* down name

* update schema

* ignore down migration on schema dump test

* fix testDirectMessageDelete test

* fix testNotes test
2024-10-25 16:30:04 +01:00
23 changed files with 1122 additions and 299 deletions
+1
View File
@@ -58,6 +58,7 @@ class ItemsModel: ObservableObject {
// this will cause reversedChatItems to be rendered without throttling // this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false @Published var isLoading = false
@Published var showLoadingProgress = false @Published var showLoadingProgress = false
@State var anchors: [ChatItem.ID] = []
init() { init() {
publisher publisher
+23 -3
View File
@@ -318,10 +318,12 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r throw r
} }
let loadItemsPerPage = 50 let loadItemsPerPage = 100
let preloadItem = 25
let idealChatListSize = 300
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) } if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r throw r
} }
@@ -329,6 +331,15 @@ func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws ->
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
if case let .apiChat(_, chat) = r { return chat.chatItems } if case let .apiChat(_, chat) = r { return chat.chatItems }
if case .chatCmdError(_, _) = r {
if case .chatError(_, let chatError) = r {
if case .errorStore(let storeError) = chatError {
if case .chatItemNotFound(_) = storeError {
itemNotFoundAlert()
}
}
}
}
throw r throw r
} }
@@ -339,7 +350,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
let im = ItemsModel.shared let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
if clearItems { if clearItems {
await MainActor.run { im.reversedChatItems = [] } await MainActor.run {
im.reversedChatItems = []
}
} }
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run { await MainActor.run {
@@ -418,6 +431,13 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]
return nil return nil
} }
func itemNotFoundAlert() {
AlertManager.shared.showAlertMsg(
title: "Message no longer available",
message: "The quoted message you are trying to view has been deleted."
)
}
private func sendMessageErrorAlert(_ r: ChatResponse) { private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))") logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -48,10 +48,18 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem { if let qi = chatItem.quotedItem {
ciQuoteView(qi) ciQuoteView(qi)
.onTapGesture { .onTapGesture {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { if let itemId = qi.itemId {
withAnimation { if !scrollToItem(itemId) {
scrollModel.scrollToItem(id: ci.id) Task {
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
// await MainActor.run {
// let _ = scrollToItem(itemId)
//}
//}
}
} }
} else {
itemNotFoundAlert()
} }
} }
} else if let itemForwarded = chatItem.meta.itemForwarded { } else if let itemForwarded = chatItem.meta.itemForwarded {
@@ -323,6 +331,18 @@ struct FramedItemView: View {
return videoWidth return videoWidth
} }
} }
// Scroll to an item, if success returns true otherwise false
private func scrollToItem(_ itemId: Int64) -> Bool {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == itemId }) {
withAnimation {
scrollModel.scrollToItem(id: ci.id)
}
return true
} else {
return false
}
}
} }
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View { @ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
@@ -0,0 +1,219 @@
//
// ChatItemGroups.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 11/11/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
/// Represents an anchor in a list of chat items, indicating where data is missing and should be loaded.
///
/// - Parameters:
/// - itemId: The unique identifier of the last item in the loaded list before the anchor.
/// This ID corresponds to an item in the chat history, ordered from older to newer items.
/// It is typically used when loading items via .around or .initial pagination when loading items
/// - indexRange: The range of indexes within `reversedChatItems` array that
/// represents the anchor. The first index in this range is the position of the anchor itself.
/// For instance, if the array `[0, 1, 2, -100-, 101]` has an anchor at index 3, `indexRange`
/// would be `3..<5`, indicating the anchor starts at index 3.
/// - indexRangeInParentItems: The range of indexes in the `ReverseList` or parent UI component
/// that considers revealed or hidden items, showing where the anchor appears in the visible list.
/// The first index in this range points to where the anchor starts in the UI.
struct AnchoredRange {
let itemId: Int64
let indexRange: Range<Int>
let indexRangeInParentItems: Range<Int>
}
struct SectionGroups {
let sections: [SectionItems]
let anchoredRanges: [AnchoredRange]
}
struct ListItem: Hashable, Equatable {
let item: ChatItem
let separation: ItemSeparation
let prevItemSeparationLargeGap: Bool
}
class SectionItems: ObservableObject {
var mergeCategory: CIMergeCategory?
var items: [ListItem]
@Published var revealed: Bool
var showAvatar: Set<Int64>
var startIndexInParentItems: Int
init(mergeCategory: CIMergeCategory?, items: [ListItem], revealed: Bool, showAvatar: Set<Int64>, startIndexInParentItems: Int) {
self.mergeCategory = mergeCategory
self.items = items
self.revealed = revealed
self.showAvatar = showAvatar
self.startIndexInParentItems = startIndexInParentItems
}
func reveal(_ reveal: Bool, revealedItems: inout Set<Int64>) {
if reveal {
for item in items {
revealedItems.insert(item.item.id)
}
} else {
for item in items {
revealedItems.remove(item.item.id)
}
}
self.revealed = reveal
}
}
func putIntoGroups(chatItems: [ChatItem], revealedItems: Set<ChatItem.ID>, itemAnchors: Array<ChatItem.ID>) -> SectionGroups {
guard !chatItems.isEmpty else { return SectionGroups(sections: [], anchoredRanges: []) }
var groups: [SectionItems] = []
var anchoredRanges: [AnchoredRange] = []
var index = 0
var unclosedAnchorIndex: Int?
var unclosedAnchorIndexInParent: Int?
var unclosedAnchorItemId: Int64?
var visibleItemIndexInParent = -1
var recent: SectionItems?
while index < chatItems.count {
let item = chatItems[index]
let next = index + 1 < chatItems.count ? chatItems[index + 1] : nil
let category = item.mergeCategory
let itemIsAnchor = itemAnchors.contains(item.id)
let itemSeparation: ItemSeparation
let prevItemSeparationLargeGap: Bool
if let recentSection = recent, index > 0, recentSection.mergeCategory == category, !itemIsAnchor {
if recentSection.revealed {
let prev = index > 0 ? chatItems[index - 1] : nil
itemSeparation = getItemSeparation(item, at: index)
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
visibleItemIndexInParent += 1
} else {
itemSeparation = getItemSeparation(item, at: index)
prevItemSeparationLargeGap = false
}
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
recentSection.items.append(listItem)
if shouldShowAvatar(current: item, older: next) {
recentSection.showAvatar.insert(item.id)
}
} else {
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
visibleItemIndexInParent += 1
if revealed {
let prev = index > 0 ? chatItems[index - 1] : nil
itemSeparation = getItemSeparation(item, at: index)
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
} else {
itemSeparation = getItemSeparation(item, at: index)
prevItemSeparationLargeGap = false
}
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
let newSection = SectionItems(
mergeCategory: item.mergeCategory,
items: [listItem],
revealed: revealed,
showAvatar: shouldShowAvatar(current: item, older: next) ? [item.id] : [],
startIndexInParentItems: visibleItemIndexInParent
)
groups.append(newSection)
recent = newSection
}
if itemIsAnchor {
if let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
anchoredRanges.append(
AnchoredRange(
itemId: unclosedId,
indexRange: unclosedIndex..<index,
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent
)
)
}
unclosedAnchorIndex = index
unclosedAnchorIndexInParent = visibleItemIndexInParent
unclosedAnchorItemId = item.id
} else if index + 1 == chatItems.count, let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
anchoredRanges.append(
AnchoredRange(
itemId: unclosedId,
indexRange: unclosedIndex..<index + 1,
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent + 1
)
)
}
index += 1
}
return SectionGroups(sections: groups, anchoredRanges: anchoredRanges)
}
func getItemSectionItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> SectionItems? {
for sec in sections {
if sec.items.firstIndex(where: { $0.item.id == itemId }) != nil {
return sec
}
}
return nil
}
func getIndexInParentItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> Int {
for sec in sections {
if let index = sec.items.firstIndex(where: { $0.item.id == itemId }) {
return sec.startIndexInParentItems + (sec.revealed ? index : 0)
}
}
return -1
}
func getNewestItemAtParentIndexOrNull(sections: [SectionItems], parentIndex: Int) -> ChatItem? {
for group in sections {
let range = group.startIndexInParentItems...(group.startIndexInParentItems + group.items.count - 1)
if range.contains(parentIndex) {
if group.revealed {
return group.items[parentIndex - group.startIndexInParentItems].item
} else {
return group.items.first?.item
}
}
}
return nil
}
private func shouldShowAvatar(current: ChatItem, older: ChatItem?) -> Bool {
if case let .groupRcv(currentMember) = current.chatDir {
if let older = older, case let .groupRcv(olderMember) = older.chatDir {
return olderMember.memberId != currentMember.memberId
}
return true // Show avatar if there is no older item or if older is not a GroupRcv
}
return false
}
private func getItemSeparationLargeGap(_ chatItem: ChatItem, at index: Int?) -> Bool {
let im = ItemsModel.shared
if let index = index, index > 0, index < im.reversedChatItems.count {
let nextItem = im.reversedChatItems[index - 1]
let sameMemberAndDirection = nextItem.chatDir.sameDirection(chatItem.chatDir)
// Return true if they are not the same direction or the time interval is more than 60 seconds.
return !sameMemberAndDirection || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
} else {
// If there is no next item or it's out of bounds, consider it a large gap.
return true
}
}
+239 -104
View File
@@ -13,6 +13,27 @@ import Combine
private let memberImageSize: CGFloat = 34 private let memberImageSize: CGFloat = 34
struct ItemSeparation: Equatable, Hashable {
let timestamp: Bool;
let largeGap: Bool;
let date: Date?
}
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return ItemSeparation(
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return ItemSeparation(timestamp: true, largeGap: true, date: nil)
}
}
struct ChatView: View { struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared @ObservedObject var im = ItemsModel.shared
@@ -32,7 +53,6 @@ struct ChatView: View {
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var loadingItems = false @State private var loadingItems = false
@State private var firstPage = false @State private var firstPage = false
@State private var revealedChatItem: ChatItem?
@State private var searchMode = false @State private var searchMode = false
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
@@ -46,6 +66,9 @@ struct ChatView: View {
@State private var selectedChatItems: Set<Int64>? = nil @State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false @State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false
@State private var initialChatItem: ChatItem? = nil
@State private var revealedItems: Set<ChatItem.ID> = []
@State private var anchors: Array<ChatItem.ID> = []
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -167,6 +190,7 @@ struct ChatView: View {
} }
.onChange(of: chatModel.chatId) { cId in .onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false showChatInfoSheet = false
firstPage = false
selectedChatItems = nil selectedChatItems = nil
scrollModel.scrollToBottom() scrollModel.scrollToBottom()
stopAudioPlayer() stopAudioPlayer()
@@ -180,7 +204,7 @@ struct ChatView: View {
dismiss() dismiss()
} }
} }
.onChange(of: revealedChatItem) { _ in .onChange(of: revealedItems.count) { _ in
NotificationCenter.postReverseListNeedsLayout() NotificationCenter.postReverseListNeedsLayout()
} }
.onChange(of: im.isLoading) { isLoading in .onChange(of: im.isLoading) { isLoading in
@@ -362,7 +386,18 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false) await markChatUnread(chat, unreadChat: false)
} }
} }
if im.reversedChatItems.count == loadItemsPerPage {
loadChatItems(chat.chatInfo, .last(count: loadItemsPerPage))
}
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
Task {
DispatchQueue.main.async {
if let firstunreadItem = self.getFirstUnreadItem() {
initialChatItem = firstunreadItem
}
}
}
} }
private func searchToolbar() -> some View { private func searchToolbar() -> some View {
@@ -416,9 +451,10 @@ struct ChatView: View {
private func chatItemsList() -> some View { private func chatItemsList() -> some View {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let mergedItems = filtered(im.reversedChatItems) let groups = putIntoGroups(chatItems: im.reversedChatItems, revealedItems: self.revealedItems, itemAnchors: self.anchors)
return GeometryReader { g in return GeometryReader { g in
ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in ReverseList(groups: groups, scrollState: $scrollModel.state, initialChatItem: $initialChatItem) { li in
let ci = li.item
let voiceNoFrame = voiceWithoutFrame(ci) let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group let maxWidth = cInfo.chatType == .group
? voiceNoFrame ? voiceNoFrame
@@ -433,13 +469,19 @@ struct ChatView: View {
maxWidth: maxWidth, maxWidth: maxWidth,
composeState: $composeState, composeState: $composeState,
selectedMember: $selectedMember, selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem, revealedChatItems: $revealedItems,
selectedChatItems: $selectedChatItems, selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems forwardedChatItems: $forwardedChatItems,
onReveal: { revealState in
if let sec = getItemSectionItems(sections: groups.sections, itemId: ci.id) {
sec.reveal(revealState, revealedItems: &self.revealedItems)
}
}
) )
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: {
loadChatItems(cInfo) } loadPage: { pagination in
loadChatItems(cInfo, pagination)
} }
.opacity(ItemsModel.shared.isLoading ? 0 : 1) .opacity(ItemsModel.shared.isLoading ? 0 : 1)
.padding(.vertical, -InvertedTableView.inset) .padding(.vertical, -InvertedTableView.inset)
@@ -471,6 +513,23 @@ struct ChatView: View {
EmptyView() EmptyView()
} }
} }
private func getFirstUnreadItem() -> ChatItem? {
var maybeItem: ChatItem? = nil
for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) {
let item = im.reversedChatItems[i]
if item.isRcvNew {
if item.mergeCategory == nil {
return maybeItem ?? item
} else {
maybeItem = item
}
} else if maybeItem != nil {
return maybeItem
}
}
return nil
}
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
static let shared = FloatingButtonModel() static let shared = FloatingButtonModel()
@@ -478,17 +537,24 @@ struct ChatView: View {
@Published var isNearBottom: Bool = true @Published var isNearBottom: Bool = true
@Published var date: Date? @Published var date: Date?
@Published var isDateVisible: Bool = false @Published var isDateVisible: Bool = false
@Published var bottomItemIndex: Int = 0
var totalUnread: Int = 0 var totalUnread: Int = 0
var isReallyNearBottom: Bool = true var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem? var hideDateWorkItem: DispatchWorkItem?
func updateOnListChange(_ listState: ListState) { func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared let im = ItemsModel.shared
let unreadBelow = let bottomItemIndex =
if let id = listState.bottomItemId, if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
{ index
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in } else {
-1
}
var unreadBelow =
if bottomItemIndex != -1 {
im.reversedChatItems[..<bottomItemIndex].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 } if chatItem.isRcvNew { unread += 1 }
} }
} else { } else {
@@ -508,6 +574,7 @@ struct ChatView: View {
it.unreadBelow = unreadBelow it.unreadBelow = unreadBelow
it.date = date it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500 it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
it.bottomItemIndex = bottomItemIndex
} }
// set floating button indication mode // set floating button indication mode
@@ -839,38 +906,133 @@ struct ChatView: View {
} }
} }
private func loadChatItems(_ cInfo: ChatInfo) { private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination = .initial(count: loadItemsPerPage)) {
Task { Task {
if loadingItems || firstPage { return } if loadingItems { return }
loadingItems = true loadingItems = true
do { do {
var reversedPage = Array<ChatItem>() let chatItems = try await apiGetChatItems(
var chatItemsAvailable = true type: cInfo.chatType,
// Load additional items until the page is +50 large after merging id: cInfo.apiId,
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { pagination: pagination,
let pagination: ChatPagination = search: searchText
if let lastItem = reversedPage.last ?? im.reversedChatItems.last { )
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
} else { if (cInfo.id != chatModel.chatId) {
.last(count: loadItemsPerPage) await MainActor.run { loadingItems = false }
return
}
let im = ItemsModel.shared
var newItems = im.reversedChatItems
switch pagination {
case .last:
await MainActor.run {
let newItemIds = Set(chatItems.map { $0.id })
var duplicateFound = false
newItems.removeAll {
let isDuplicate = newItemIds.contains($0.id)
duplicateFound = duplicateFound || isDuplicate
return isDuplicate
} }
let chatItems = try await apiGetChatItems(
type: cInfo.chatType, if !duplicateFound {
id: cInfo.apiId, if let existingItem = im.reversedChatItems.first {
pagination: pagination, anchors = [existingItem.id]
search: searchText }
) }
chatItemsAvailable = !chatItems.isEmpty
reversedPage.append(contentsOf: chatItems.reversed()) newItems.insert(contentsOf: chatItems.reversed(), at: 0)
} im.reversedChatItems = newItems
await MainActor.run { loadingItems = false
if reversedPage.count == 0 { }
firstPage = true case .initial:
} else { await MainActor.run {
im.reversedChatItems.append(contentsOf: reversedPage) im.reversedChatItems = chatItems.reversed()
anchors = []
loadingItems = false
}
case let .after(chatItemId, _):
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
return
}
let wasSize = newItems.count
let newItemIds = Set(chatItems.map { $0.id })
let indexInAnchors = anchors.firstIndex { $0 == chatItemId }
var anchorAfterChatItem: [Int64] = []
if let indexInAnchors = indexInAnchors, indexInAnchors + 1 <= anchors.count {
anchorAfterChatItem = Array(anchors[indexInAnchors + 1..<anchors.count])
}
var anchorsToRemove = Set<Int64>()
var reachedBottom: Bool = false
newItems.removeAll { item in
let isDuplicate = newItemIds.contains(item.id)
if indexInAnchors != nil && newItemIds.contains(item.id) {
if anchorAfterChatItem.contains(item.id) {
anchorAfterChatItem.removeAll { $0 == item.id }
anchorsToRemove.insert(item.id)
} else if reachedBottom == false && anchorAfterChatItem.isEmpty {
// We passed all anchors and found a duplicated item below all of them, indicating no more anchors below the loaded items.
reachedBottom = true
}
}
return isDuplicate
}
let insertAt = indexInCurrentItems - (wasSize - newItems.count)
newItems.insert(contentsOf: chatItems.reversed(), at: insertAt)
await MainActor.run {
im.reversedChatItems = newItems
var newAnchors = anchors.filter { !anchorsToRemove.contains($0) }
if reachedBottom {
newAnchors = []
} else {
if let enlargedAnchorIndex = anchors.firstIndex(where: { $0 == chatItemId }) {
// Move the anchor to the end of the loaded items.
newAnchors[enlargedAnchorIndex] = chatItems.last?.id ?? newAnchors[enlargedAnchorIndex]
}
}
anchors = newAnchors
loadingItems = false
}
case let .before(chatItemId, _):
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
return
}
let newItemIds = Set(chatItems.map { $0.id })
newItems.removeAll { newItemIds.contains($0.id) }
newItems.insert(contentsOf: chatItems.reversed(), at: min(indexInCurrentItems + 1, newItems.count))
await MainActor.run {
if chatItems.count == 0 || newItems.count == im.reversedChatItems.count {
firstPage = true
} else {
im.reversedChatItems = newItems
}
anchors = anchors.filter { !newItemIds.contains($0) }
loadingItems = false
}
case .around(_, _):
let newItemIds = Set(chatItems.map { $0.id })
newItems.removeAll { newItemIds.contains($0.id) }
newItems.insert(contentsOf: chatItems, at: 0)
await MainActor.run {
im.reversedChatItems = newItems
if let lastItemId = chatItems.last?.id {
anchors.insert(lastItemId, at: 0)
}
loadingItems = false
} }
loadingItems = false
} }
} catch let error { } catch let error {
logger.error("apiGetChat error: \(responseError(error))") logger.error("apiGetChat error: \(responseError(error))")
await MainActor.run { loadingItems = false } await MainActor.run { loadingItems = false }
@@ -892,7 +1054,7 @@ struct ChatView: View {
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var selectedMember: GMember? @Binding var selectedMember: GMember?
@Binding var revealedChatItem: ChatItem? @Binding var revealedChatItems: Set<ChatItem.ID>
@State private var deletingItem: ChatItem? = nil @State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false @State private var showDeleteMessage = false
@@ -907,25 +1069,8 @@ struct ChatView: View {
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false @State private var markedRead = false
let onReveal: (Bool) -> Void
var revealed: Bool { chatItem == revealedChatItem } var revealed: Bool { revealedChatItems.contains(chatItem.id) }
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return (
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return (timestamp: true, largeGap: true, date: nil)
}
}
var body: some View { var body: some View {
let currIndex = m.getChatItemIndex(chatItem) let currIndex = m.getChatItemIndex(chatItem)
@@ -933,46 +1078,7 @@ struct ChatView: View {
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden) let range = itemsRange(currIndex, prevHidden)
let timeSeparation = getItemSeparation(chatItem, at: currIndex) let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared func markAsRead() {
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range]))
VStack(spacing: 0) {
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
}
} else {
VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
}
.onAppear {
if markedRead { if markedRead {
return return
} else { } else {
@@ -991,6 +1097,30 @@ struct ChatView: View {
} }
} }
} }
return Group {
VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
.onAppear {
markAsRead()
}
.onChange(of: ChatView.FloatingButtonModel.shared.bottomItemIndex) { _ in
markAsRead()
}
} }
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] { private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
@@ -1008,8 +1138,13 @@ struct ChatView: View {
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
Task { Task {
_ = try? await Task.sleep(nanoseconds: 600_000000) _ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id { let currIndex = m.getChatItemIndex(chatItem)
await op() if let currIndex = currIndex, currIndex >= ChatView.FloatingButtonModel.shared.bottomItemIndex - 3 {
if m.chatId == chat.chatInfo.id {
await op()
}
} else {
markedRead = false
} }
} }
} }
@@ -1573,7 +1708,7 @@ struct ChatView: View {
private func hideButton() -> Button<some View> { private func hideButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = nil onReveal(false)
} }
} label: { } label: {
Label( Label(
@@ -1648,7 +1783,7 @@ struct ChatView: View {
private func revealButton(_ ci: ChatItem) -> Button<some View> { private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = ci onReveal(true)
} }
} label: { } label: {
Label( Label(
@@ -1661,7 +1796,7 @@ struct ChatView: View {
private func expandButton() -> Button<some View> { private func expandButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = chatItem onReveal(true)
} }
} label: { } label: {
Label( Label(
@@ -1674,7 +1809,7 @@ struct ChatView: View {
private func shrinkButton() -> Button<some View> { private func shrinkButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = nil onReveal(false)
} }
} label: { } label: {
Label ( Label (
+89 -22
View File
@@ -12,14 +12,15 @@ import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top /// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable { struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem> let groups: SectionGroups
@Binding var scrollState: ReverseListScrollModel.State @Binding var scrollState: ReverseListScrollModel.State
@Binding var initialChatItem: ChatItem?
/// Closure, that returns user interface for a given item /// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content let content: (ListItem) -> Content
let loadPage: () -> Void let loadPage: (_ pagination: ChatPagination) -> Void
func makeUIViewController(context: Context) -> Controller { func makeUIViewController(context: Context) -> Controller {
Controller(representer: self) Controller(representer: self)
@@ -27,18 +28,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func updateUIViewController(_ controller: Controller, context: Context) { func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty { if case let .scrollingTo(destination) = scrollState, !groups.sections.isEmpty {
controller.view.layer.removeAllAnimations() controller.view.layer.removeAllAnimations()
switch destination { switch destination {
case .nextPage: case .nextPage:
controller.scrollToNextPage() controller.scrollToNextPage()
case let .item(id): case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) controller.scroll(to: getIndexInParentItems(sections: groups.sections, itemId: id), position: .bottom)
case .bottom: case .bottom:
controller.scroll(to: 0, position: .top) controller.scroll(to: 0, position: .top)
} }
} else { } else {
controller.update(items: items) controller.update(groups: groups)
} }
} }
@@ -46,10 +47,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
class Controller: UITableViewController { class Controller: UITableViewController {
private enum Section { case main } private enum Section { case main }
var representer: ReverseList var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>! private var dataSource: UITableViewDiffableDataSource<Section, ListItem>!
private var itemCount: Int = 0 private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>() private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
private var revealedItems: Array<ListItem> = []
init(representer: ReverseList) { init(representer: ReverseList) {
self.representer = representer self.representer = representer
@@ -75,11 +77,16 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
// 3. Configure data source // 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>( self.dataSource = UITableViewDiffableDataSource<Section, ListItem>(
tableView: tableView tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in ) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 { if self.representer.scrollState == .atDestination, self.representer.initialChatItem == nil {
self.representer.loadPage() if indexPath.item > self.itemCount - preloadItem,
let item = getNewestItemAtParentIndexOrNull(sections: self.representer.groups.sections, parentIndex: self.itemCount - 1) {
self.representer.loadPage(.before(chatItemId: item.id, count: loadItemsPerPage))
} else if let item = self.getFirstItemAfterPlacholder(indexPath) {
self.representer.loadPage(.after(chatItemId: item.id, count: loadItemsPerPage))
}
} }
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@@ -149,6 +156,29 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
tableView.clipsToBounds = false tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false parent?.viewIfLoaded?.clipsToBounds = false
} }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let cItem = self.representer.initialChatItem {
let index = getIndexInParentItems(sections: self.representer.groups.sections, itemId: cItem.id)
if index == -1 {
return
}
let indexPath = IndexPath(row: index, section: 0)
if !isVisible(indexPath: indexPath) {
if tableView.numberOfRows(inSection: indexPath.section) > indexPath.row {
let cellRect = tableView.rectForRow(at: indexPath)
tableView.setContentOffset(CGPoint(x: 0, y: cellRect.maxY - tableView.bounds.height), animated: false)
}
}
Task {
DispatchQueue.main.async {
self.representer.initialChatItem = nil
}
}
}
}
/// Scrolls up /// Scrolls up
func scrollToNextPage() { func scrollToNextPage() {
@@ -169,6 +199,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
animated = true animated = true
} }
if let index, tableView.numberOfRows(inSection: 0) != 0 { if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow( tableView.scrollToRow(
at: IndexPath(row: index, section: 0), at: IndexPath(row: index, section: 0),
@@ -181,18 +212,49 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: animated animated: animated
) )
} }
Task { representer.scrollState = .atDestination }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
self.representer.scrollState = .atDestination
}
}
} }
func update(items: [ChatItem]) { func update(groups: SectionGroups) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>() var snapshot = NSDiffableDataSourceSnapshot<Section, ListItem>()
revealedItems.removeAll()
groups.sections.forEach { sc in
if sc.revealed {
revealedItems.append(contentsOf: sc.items)
} else if let item = sc.items.first {
revealedItems.append(item)
}
}
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(items) snapshot.appendItems(revealedItems, toSection: .main)
dataSource.defaultRowAnimation = .none dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot, let countDiff = max(0, revealedItems.count - itemCount)
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1 if tableView.contentOffset.y == 100, itemCount < revealedItems.count, itemCount > 0 {
) dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.scrollToRow(
at: IndexPath(row: countDiff, section: 0),
at: .top,
animated: false
)
} else {
tableView.beginUpdates()
dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.endUpdates()
}
// Sets content offset on initial load // Sets content offset on initial load
if itemCount == 0 { if itemCount == 0 {
tableView.setContentOffset( tableView.setContentOffset(
@@ -200,7 +262,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: false animated: false
) )
} }
itemCount = items.count itemCount = revealedItems.count
updateFloatingButtons.send() updateFloatingButtons.send()
} }
@@ -210,17 +272,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func getListState() -> ListState? { func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows, if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count { visibleRows.last?.item ?? 0 < revealedItems.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? = let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs revealedItems[lastVisible.item].item.meta.itemTs
} else { } else {
nil nil
} }
let bottomItemId: ChatItem.ID? = let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id revealedItems[firstVisible.item].item.id
} else { } else {
nil nil
} }
@@ -238,6 +301,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false } } else { false }
} }
private func getFirstItemAfterPlacholder(_ indexPath: IndexPath) -> ChatItem? {
return nil
}
} }
/// `UIHostingConfiguration` back-port for iOS14 and iOS15 /// `UIHostingConfiguration` back-port for iOS14 and iOS15
@@ -200,6 +200,7 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
@@ -544,6 +545,7 @@
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemGroups.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
@@ -734,6 +736,7 @@
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */,
); );
path = Chat; path = Chat;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1483,6 +1486,7 @@
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
+5 -1
View File
@@ -1133,12 +1133,16 @@ public enum ChatPagination {
case last(count: Int) case last(count: Int)
case after(chatItemId: Int64, count: Int) case after(chatItemId: Int64, count: Int)
case before(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int)
case around(chatItemId: Int64, count: Int)
case initial(count: Int)
var cmdString: String { var cmdString: String {
switch self { switch self {
case let .last(count): return "count=\(count)" case let .last(count): return "count=\(count)"
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
case let .initial(count): return "initial=\(count)"
} }
} }
} }
+1 -1
View File
@@ -2663,7 +2663,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
item.isLiveDummy = true item.isLiveDummy = true
return item return item
} }
public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem { public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem {
ChatItem( ChatItem(
chatDir: chatDir ?? .directSnd, chatDir: chatDir ?? .directSnd,
@@ -381,6 +381,14 @@ fun ComposeView(
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
// val composedMessages = Array(300) { index ->
// ComposedMessage(
// file,
// quoted,
// MsgContent.MCText("$index")
// )
// }.toList()
val chatItems = if (chat.chatInfo.chatType == ChatType.Local) val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItems( chatModel.controller.apiCreateChatItems(
rh = chat.remoteHostId, rh = chat.remoteHostId,
@@ -605,7 +605,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi
listGroups count pending = listGroups count pending =
readTVarIO (groupRegs st) >>= \groups -> do readTVarIO (groupRegs st) >>= \groups -> do
grs <- grs <-
if pending if pending
then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups
else pure groups else pure groups
sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "") sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "")
+1
View File
@@ -150,6 +150,7 @@ library
Simplex.Chat.Migrations.M20240920_user_order Simplex.Chat.Migrations.M20240920_user_order
Simplex.Chat.Migrations.M20241008_indexes Simplex.Chat.Migrations.M20241008_indexes
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared
+2
View File
@@ -8301,6 +8301,8 @@ chatCommandP =
(CPLast <$ "count=" <*> A.decimal) (CPLast <$ "count=" <*> A.decimal)
<|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPAround <$ "around=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPInitial <$ "initial=" <*> A.decimal)
paginationByTimeP = paginationByTimeP =
(PTLast <$ "count=" <*> A.decimal) (PTLast <$ "count=" <*> A.decimal)
<|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal) <|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal)
+2
View File
@@ -839,6 +839,8 @@ data ChatPagination
= CPLast Int = CPLast Int
| CPAfter ChatItemId Int | CPAfter ChatItemId Int
| CPBefore ChatItemId Int | CPBefore ChatItemId Int
| CPAround ChatItemId Int
| CPInitial Int
deriving (Show) deriving (Show)
data PaginationByTime data PaginationByTime
+6
View File
@@ -239,6 +239,12 @@ chatItemTs (CChatItem _ ci) = chatItemTs' ci
chatItemTs' :: ChatItem c d -> UTCTime chatItemTs' :: ChatItem c d -> UTCTime
chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs
chatItemCreatedAt :: CChatItem c -> UTCTime
chatItemCreatedAt (CChatItem _ ci) = chatItemCreatedAt' ci
chatItemCreatedAt' :: ChatItem c d -> UTCTime
chatItemCreatedAt' ChatItem {meta = CIMeta {createdAt}} = createdAt
chatItemTimed :: ChatItem c d -> Maybe CITimed chatItemTimed :: ChatItem c d -> Maybe CITimed
chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed
@@ -0,0 +1,34 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241023_chat_item_autoincrement_id :: Query
m20241023_chat_item_autoincrement_id =
[sql|
INSERT INTO sqlite_sequence (name, seq)
SELECT 'chat_items', MAX(ROWID) FROM chat_items;
PRAGMA writable_schema=1;
UPDATE sqlite_master SET sql = replace(sql, 'INTEGER PRIMARY KEY', 'INTEGER PRIMARY KEY AUTOINCREMENT')
WHERE name = 'chat_items' AND type = 'table';
PRAGMA writable_schema=0;
|]
down_m20241023_chat_item_autoincrement_id :: Query
down_m20241023_chat_item_autoincrement_id =
[sql|
DELETE FROM sqlite_sequence WHERE name = 'chat_items';
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'INTEGER PRIMARY KEY AUTOINCREMENT', 'INTEGER PRIMARY KEY')
WHERE name = 'chat_items' AND type = 'table';
PRAGMA writable_schema=0;
|]
+2 -2
View File
@@ -360,7 +360,7 @@ CREATE TABLE pending_group_messages(
updated_at TEXT NOT NULL DEFAULT(datetime('now')) updated_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE chat_items( CREATE TABLE chat_items(
chat_item_id INTEGER PRIMARY KEY, chat_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE,
@@ -399,6 +399,7 @@ CREATE TABLE chat_items(
fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL,
via_proxy INTEGER via_proxy INTEGER
); );
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE chat_item_messages( CREATE TABLE chat_item_messages(
chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE,
message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE,
@@ -429,7 +430,6 @@ CREATE TABLE commands(
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')) updated_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE settings( CREATE TABLE settings(
settings_id INTEGER PRIMARY KEY, settings_id INTEGER PRIMARY KEY,
chat_item_ttl INTEGER, chat_item_ttl INTEGER,
+345 -151
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE GADTs #-} {-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-} {-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PatternSynonyms #-}
@@ -951,33 +952,37 @@ getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagin
getDirectChat db vr user contactId pagination search_ = do getDirectChat db vr user contactId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
ct <- getContact db vr user contactId ct <- getContact db vr user contactId
liftIO $ case pagination of case pagination of
CPLast count -> getDirectChatLast_ db user ct count search CPLast count -> liftIO $ getDirectChatLast_ db user ct count search
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getDirectChatInitial_ db user ct count
-- the last items in reverse order (the last item in the conversation is the first in the returned list) -- the last items in reverse order (the last item in the conversation is the first in the returned list)
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do getDirectChatLast_ db user ct count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsLast_ chatItemIds <- getDirectChatItemIdsLast_ db user ct count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemIdsLast_ :: IO [ChatItemId] getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId]
getDirectChatItemIdsLast_ = getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, count) (userId, contactId, search, count)
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect) safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId = safeGetDirectItem db user ct currentTs itemId =
@@ -1021,51 +1026,96 @@ getDirectChatItemLast db user@User {userId} contactId = do
(userId, contactId) (userId, contactId)
getDirectChatItem db user contactId chatItemId getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsAfter_ afterChatItem <- getDirectChatItem db user contactId afterChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) chatItems stats pure $ Chat (DirectChat ct) chatItems stats
where
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
getDirectChatItemIdsAfter_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id > ?
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, contactId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db user ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemsIdsBefore_ beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
getDirectChatItemsIdsBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at < ? OR (created_at = ? AND chat_item_id < ?))
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getDirectChatItem db user contactId aroundItemId
beforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraAfterIds
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraBeforeIds
pure $ Chat (DirectChat ct) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
firstUnreadItemId_ <- liftIO getDirectChatMinUnreadItemId_
case firstUnreadItemId_ of
Just firstUnreadItemId -> getDirectChatAround_ db user ct firstUnreadItemId count ""
Nothing -> liftIO $ getDirectChatLast_ db user ct count ""
where where
getDirectChatItemsIdsBefore_ :: IO [ChatItemId] getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getDirectChatItemsIdsBefore_ = getDirectChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND contact_id = ? AND item_status = ?
AND chat_item_id < ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, contactId, search, beforeChatItemId, count) (userId, contactId, CISRcvNew)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChat db vr user groupId pagination search_ = do getGroupChat db vr user groupId pagination search_ = do
@@ -1075,28 +1125,32 @@ getGroupChat db vr user groupId pagination search_ = do
CPLast count -> liftIO $ getGroupChatLast_ db user g count search CPLast count -> liftIO $ getGroupChatLast_ db user g count search
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getGroupChatInitial_ db user g count
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do getGroupChatLast_ db user g count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getGroupChatItemIdsLast_ chatItemIds <- getGroupChatItemIdsLast_ db user g count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId]
getGroupChatItemIdsLast_ = getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY item_ts DESC, chat_item_id DESC ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, count) (userId, groupId, search, count)
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup) safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId = safeGetGroupItem db user g currentTs itemId =
@@ -1141,83 +1195,130 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
getGroupChatItem db user groupId chatItemId getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId count search = do getGroupChatAfter_ db user g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ db user g afterChatItemId count search (chatItemTs afterChatItem)
currentTs <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) chatItems stats pure $ Chat (GroupChat g) chatItems stats
where
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsAfter_ afterChatItemTs = getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
ORDER BY item_ts ASC, chat_item_id ASC ORDER BY item_ts ASC, chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count) (userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId count search = do getGroupChatBefore_ db user g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ db user g beforeChatItemId count search (chatItemTs beforeChatItem)
currentTs <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
getGroupChatItemIdsBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId count search beforeChatItemTs =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getGroupChatItem db user groupId aroundItemId
beforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g aroundItemId fetchCountBefore search (chatItemTs middleChatItem)
afterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g aroundItemId fetchCountAfter search (chatItemTs middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g (last afterIds) remainingBefore search (chatItemTs (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraAfterIds
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g (last beforeIds) remainingAfter search (chatItemTs (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraBeforeIds
pure $ Chat (GroupChat g) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
firstUnreadItemId_ <- liftIO getGroupChatMinUnreadItemId_
case firstUnreadItemId_ of
Just firstUnreadItemId -> getGroupChatAround_ db user g firstUnreadItemId count ""
Nothing -> liftIO $ getGroupChatLast_ db user g count ""
where where
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getGroupChatItemIdsBefore_ beforeChatItemTs = getGroupChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND group_id = ? AND item_status = ?
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count) (userId, groupId, CISRcvNew)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChat db user folderId pagination search_ = do getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId nf <- getNoteFolder db user folderId
liftIO $ case pagination of case pagination of
CPLast count -> getLocalChatLast_ db user nf count search CPLast count -> liftIO $ getLocalChatLast_ db user nf count search
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
getLocalChatInitial_ db user nf count
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do getLocalChatLast_ db user nf count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsLast_ chatItemIds <- getLocalChatItemIdsLast_ db user nf count search
currentTs <- getCurrentTime currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsLast_ :: IO [ChatItemId] getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId]
getLocalChatItemIdsLast_ = getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT chat_item_id
FROM chat_items FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, noteFolderId, search, count) (userId, noteFolderId, search, count)
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal) safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId = safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
@@ -1245,51 +1346,105 @@ safeToLocalItem currentTs itemId = \case
file = Nothing file = Nothing
} }
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsAfter_ afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) chatItems stats pure $ Chat (LocalChat nf) chatItems stats
where
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
getLocalChatItemIdsAfter_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id > ?
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, noteFolderId, search, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, noteFolderId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsBefore_ beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
currentTs <- getCurrentTime chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
getLocalChatItemIdsBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
AND (created_at < ? OR (created_at = ? AND chat_item_id < ?))
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, noteFolderId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getLocalChatItem db user noteFolderId aroundItemId
beforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraAfterIds
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraBeforeIds
pure $ Chat (LocalChat nf) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal)
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 ""
let items = chatItems chat
if null items || length items == count
then pure chat
else do
let remainingCount = count - length items
let afterId = cchatItemId $ last items
after <- getLocalChatAfter_ db user nf afterId remainingCount ""
pure $ chat {chatItems = chatItems chat <> chatItems after}
Nothing -> liftIO $ getLocalChatLast_ db user nf count ""
where where
getLocalChatItemIdsBefore_ :: IO [ChatItemId] getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getLocalChatItemIdsBefore_ = getLocalChatMinUnreadItemId_ =
map fromOnly fmap join . maybeFirstRow fromOnly $
<$> DB.query DB.query
db db
[sql| [sql|
SELECT chat_item_id SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
AND chat_item_id < ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|] |]
(userId, noteFolderId, search, beforeChatItemId, count) (userId, noteFolderId, CISRcvNew)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case toChatItemRef = \case
@@ -1581,6 +1736,13 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
CPLast count -> liftIO $ getAllChatItemsLast_ count CPLast count -> liftIO $ getAllChatItemsLast_ count
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId
CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
firstUnreadItemId <- liftIO getFirstUnreadItemId_
case firstUnreadItemId of
Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId
Nothing -> liftIO $ getAllChatItemsLast_ count
mapM (uncurry (getAChatItem db vr user)) itemRefs mapM (uncurry (getAChatItem db vr user)) itemRefs
where where
search = fromMaybe "" search_ search = fromMaybe "" search_
@@ -1624,6 +1786,31 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
LIMIT ? LIMIT ?
|] |]
(userId, search, beforeTs, beforeTs, beforeId, count) (userId, search, beforeTs, beforeTs, beforeId, count)
getChatItem chatId =
DB.query
db
[sql|
SELECT chat_item_id, contact_id, group_id, note_folder_id
FROM chat_items
WHERE chat_item_id = ?
|]
(Only chatId)
getAllChatItemsAround_ aroundId count aroundTs = do
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
itemsBefore <- getAllChatItemsBefore_ aroundId fetchCountBefore aroundTs
item <- getChatItem aroundId
itemsAfter <- getAllChatItemsAfter_ aroundId fetchCountAfter aroundTs
pure $ itemsBefore <> item <> itemsAfter
getFirstUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND item_status = ?
|]
(userId, CISRcvNew)
getChatItemIdsByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO [ChatItemId] getChatItemIdsByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO [ChatItemId]
getChatItemIdsByAgentMsgId db connId msgId = getChatItemIdsByAgentMsgId db connId msgId =
@@ -2650,3 +2837,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do
LIMIT ? LIMIT ?
|] |]
(userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) (userId, groupId, rcvMsgContentTag, sndMsgContentTag, count)
divideFetchCountAround_ :: Int -> (Int, Int)
divideFetchCountAround_ count =
let fetchCountEachSide = count `div` 2
fetchCountBefore = fetchCountEachSide + if count `mod` 2 /= 0 then 1 else 0
fetchCountAfter = fetchCountEachSide
in (fetchCountBefore, fetchCountAfter)
+3 -1
View File
@@ -114,6 +114,7 @@ import Simplex.Chat.Migrations.M20240827_calls_uuid
import Simplex.Chat.Migrations.M20240920_user_order import Simplex.Chat.Migrations.M20240920_user_order
import Simplex.Chat.Migrations.M20241008_indexes import Simplex.Chat.Migrations.M20241008_indexes
import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@@ -227,7 +228,8 @@ schemaMigrations =
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid), ("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid),
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order), ("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes), ("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes),
("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id) ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id),
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date
+59 -8
View File
@@ -66,6 +66,7 @@ chatDirectTests = do
it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact
it "should send multiline message" testMultilineMessage it "should send multiline message" testMultilineMessage
it "send large message" testLargeMessage it "send large message" testLargeMessage
it "initial chat pagination" testChatPaginationInitial
describe "batch send messages" $ do describe "batch send messages" $ do
it "send multiple messages api" testSendMulti it "send multiple messages api" testSendMulti
it "send multiple timed messages" testSendMultiTimed it "send multiple timed messages" testSendMultiTimed
@@ -210,6 +211,7 @@ testAddContact = versionTestMatrix2 runTestAddContact
-- pagination -- pagination
alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")]) alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")])
alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")]) alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")])
alice #$> ("/_get chat @2 around=" <> itemId 2 <> " count=3", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")])
-- search -- search
alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")]) alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")])
-- read messages -- read messages
@@ -360,6 +362,49 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
let itemIds = intercalate "," $ map show [i - 3 .. i] let itemIds = intercalate "," $ map show [i - 3 .. i]
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
testChatPaginationInitial :: HasCallStack => FilePath -> IO ()
testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
connectUsers alice bob
-- Wait, otherwise ids are going to be wrong.
threadDelay 1000000
-- Send messages from alice to bob
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("@bob " <> show n)
-- Bob receives the messages.
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
-- All messages are unread for bob, should return area around unread
bob #$> ("/_get chat @2 initial=3", chat, [(0, "Audio/video calls: enabled"), (0, "1"), (0, "2")])
-- Read next 2 items
let itemIds = intercalate "," $ map itemId [1 .. 2]
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
bob #$> ("/_get chat @2 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
-- Read all items
bob #$> ("/_read chat @2", id, "ok")
bob #$> ("/_get chat @2 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
-- Clear chat, send a few extra message and assert page size is consistent
bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY")
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("@bob " <> show n)
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let newItemIds = intercalate "," $ map itemId [11 .. 12] -- Read, 1, 2
bob #$> ("/_read chat items @2 " <> newItemIds, id, "ok")
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let allButLastId = intercalate "," $ map itemId [13 .. 19] -- Read all but last
bob #$> ("/_read chat items @2 " <> allButLastId, id, "ok")
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
where
opts =
testOpts
{ markRead = False
}
testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO ()
testDuplicateContactsSeparate = testDuplicateContactsSeparate =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
@@ -791,7 +836,7 @@ testDirectMessageDelete =
alice @@@ [("@bob", lastChatFeature)] alice @@@ [("@bob", lastChatFeature)]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures) alice #$> ("/_get chat @2 count=100", chat, chatFeatures)
-- alice: msg id 1 -- alice: msg id 3
bob ##> ("/_update item @2 " <> itemId 2 <> " text hey alice") bob ##> ("/_update item @2 " <> itemId 2 <> " text hey alice")
bob <# "@alice [edited] > hello 🙂" bob <# "@alice [edited] > hello 🙂"
bob <## " hey alice" bob <## " hey alice"
@@ -806,12 +851,12 @@ testDirectMessageDelete =
alice @@@ [("@bob", "hey alice [marked deleted]")] alice @@@ [("@bob", "hey alice [marked deleted]")]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hey alice [marked deleted]")]) alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hey alice [marked deleted]")])
-- alice: deletes msg id 1 that was broadcast deleted by bob -- alice: deletes msg id 3 that was broadcast deleted by bob
alice #$> ("/_delete item @2 " <> itemId 1 <> " internal", id, "message deleted") alice #$> ("/_delete item @2 " <> itemId 3 <> " internal", id, "message deleted")
alice @@@ [("@bob", lastChatFeature)] alice @@@ [("@bob", lastChatFeature)]
alice #$> ("/_get chat @2 count=100", chat, chatFeatures) alice #$> ("/_get chat @2 count=100", chat, chatFeatures)
-- alice: msg id 1, bob: msg id 3 (quoting message alice deleted locally) -- alice: msg id 4, bob: msg id 3 (quoting message alice deleted locally)
bob `send` "> @alice (hello 🙂) do you receive my messages?" bob `send` "> @alice (hello 🙂) do you receive my messages?"
bob <# "@alice > hello 🙂" bob <# "@alice > hello 🙂"
bob <## " do you receive my messages?" bob <## " do you receive my messages?"
@@ -819,14 +864,14 @@ testDirectMessageDelete =
alice <## " do you receive my messages?" alice <## " do you receive my messages?"
alice @@@ [("@bob", "do you receive my messages?")] alice @@@ [("@bob", "do you receive my messages?")]
alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "do you receive my messages?"), Just (1, "hello 🙂"))]) alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "do you receive my messages?"), Just (1, "hello 🙂"))])
alice #$> ("/_delete item @2 " <> itemId 1 <> " broadcast", id, "cannot delete this item") alice #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "cannot delete this item")
-- alice: msg id 2, bob: msg id 4 -- alice: msg id 5, bob: msg id 4
bob #> "@alice how are you?" bob #> "@alice how are you?"
alice <# "bob> how are you?" alice <# "bob> how are you?"
-- alice: deletes msg id 2 -- alice: deletes msg id 5
alice #$> ("/_delete item @2 " <> itemId 2 <> " internal", id, "message deleted") alice #$> ("/_delete item @2 " <> itemId 5 <> " internal", id, "message deleted")
-- bob: marks deleted msg id 4 (that alice deleted locally) -- bob: marks deleted msg id 4 (that alice deleted locally)
bob #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "message marked deleted") bob #$> ("/_delete item @2 " <> itemId 4 <> " broadcast", id, "message marked deleted")
@@ -2340,6 +2385,12 @@ testUserPrivacy =
"bob> Voice messages: enabled", "bob> Voice messages: enabled",
"bob> Audio/video calls: enabled" "bob> Audio/video calls: enabled"
] ]
alice ##> "/_get items around=11 count=3"
alice
<##? [ "bob> Message reactions: enabled",
"bob> Voice messages: enabled",
"bob> Audio/video calls: enabled"
]
alice ##> "/_get items after=12 count=10" alice ##> "/_get items after=12 count=10"
alice alice
<##? [ "@bob hello", <##? [ "@bob hello",
+47
View File
@@ -36,6 +36,7 @@ chatGroupTests = do
describe "chat groups" $ do describe "chat groups" $ do
describe "add contacts, create group and send/receive messages" testGroupMatrix describe "add contacts, create group and send/receive messages" testGroupMatrix
it "mark multiple messages as read" testMarkReadGroup it "mark multiple messages as read" testMarkReadGroup
it "initial chat pagination" testChatPaginationInitial
it "v1: add contacts, create group and send/receive messages" testGroup it "v1: add contacts, create group and send/receive messages" testGroup
it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages
it "send large message" testGroupLargeMessage it "send large message" testGroupLargeMessage
@@ -344,6 +345,7 @@ testGroupShared alice bob cath checkMessages directConnections = do
-- so we take into account group event items as well as sent group invitations in direct chats -- so we take into account group event items as well as sent group invitations in direct chats
alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")])
alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")])
alice #$> ("/_get chat #1 around=" <> msgItem2 <> " count=6", chat, [(0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) -- expecting only 4 items since there is only 1 item after
alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")])
bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")]
bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")])
@@ -374,6 +376,51 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
let itemIds = intercalate "," $ map show [i - 3 .. i] let itemIds = intercalate "," $ map show [i - 3 .. i]
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
testChatPaginationInitial :: HasCallStack => FilePath -> IO ()
testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
createGroup2 "team" alice bob
-- Wait, otherwise ids are going to be wrong.
threadDelay 1000000
lastEventId <- (read :: String -> Int) <$> lastItemId bob
let groupItemId n = show $ lastEventId + n
-- Send messages from alice to bob
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("#team " <> show n)
-- Bob receives the messages.
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
-- All messages are unread for bob, should return area around unread
bob #$> ("/_get chat #1 initial=3", chat, [(0, "connected"), (0, "1"), (0, "2")])
-- Read next 2 items
let itemIds = intercalate "," $ map groupItemId [1 .. 2]
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
bob #$> ("/_get chat #1 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
-- Read all items
bob #$> ("/_read chat #1", id, "ok")
bob #$> ("/_get chat #1 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
-- Clear chat, send a few extra message and assert page size is consistent
bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("#team " <> show n)
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let newItemIds = intercalate "," $ map groupItemId [11 .. 12] -- Read, 1, 2
bob #$> ("/_read chat items #1 " <> newItemIds, id, "ok")
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let allButLastId = intercalate "," $ map groupItemId [13 .. 19] -- Read all but last
bob #$> ("/_read chat items #1 " <> allButLastId, id, "ok")
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
where
opts =
testOpts
{ markRead = False
}
testGroupLargeMessage :: HasCallStack => FilePath -> IO () testGroupLargeMessage :: HasCallStack => FilePath -> IO ()
testGroupLargeMessage = testGroupLargeMessage =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
+5 -1
View File
@@ -51,7 +51,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice ##> "/chats" alice ##> "/chats"
alice /* "ahoy!" alice /* "ahoy!"
alice ##> "/_update item *1 1 text Greetings." alice ##> "/_update item *1 2 text Greetings."
alice ##> "/tail *" alice ##> "/tail *"
alice <# "* Greetings." alice <# "* Greetings."
@@ -102,6 +102,10 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")]) alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")])
alice #$> ("/_get chat *1 around=2 count=1", chat, [(1, "memento mori")])
alice #$> ("/_get chat *1 around=2 count=3", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock")])
alice #$> ("/_get chat *1 around=3 count=10", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 around=4 count=3", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")]) alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")])
+3 -1
View File
@@ -102,7 +102,9 @@ skipComparisonForDownMigrations =
-- table and indexes move down to the end of the file -- table and indexes move down to the end of the file
"20231215_recreate_msg_deliveries", "20231215_recreate_msg_deliveries",
-- on down migration idx_msg_deliveries_agent_ack_cmd_id index moves down to the end of the file -- on down migration idx_msg_deliveries_agent_ack_cmd_id index moves down to the end of the file
"20240313_drop_agent_ack_cmd_id" "20240313_drop_agent_ack_cmd_id",
-- on down migration chat_item_autoincrement_id makes sequence table creation move down on the file
"20241023_chat_item_autoincrement_id"
] ]
getSchema :: FilePath -> FilePath -> IO String getSchema :: FilePath -> FilePath -> IO String