Compare commits

...

42 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
16 changed files with 887 additions and 192 deletions
+1
View File
@@ -58,6 +58,7 @@ class ItemsModel: ObservableObject {
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
@State var anchors: [ChatItem.ID] = []
init() {
publisher
+23 -3
View File
@@ -318,10 +318,12 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
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 {
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) }
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] {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
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
}
@@ -339,7 +350,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
let im = ItemsModel.shared
m.chatItemStatuses = [:]
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)
await MainActor.run {
@@ -418,6 +431,13 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]
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) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
@@ -48,10 +48,18 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
.onTapGesture {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
scrollModel.scrollToItem(id: ci.id)
if let itemId = qi.itemId {
if !scrollToItem(itemId) {
Task {
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
// await MainActor.run {
// let _ = scrollToItem(itemId)
//}
//}
}
}
} else {
itemNotFoundAlert()
}
}
} else if let itemForwarded = chatItem.meta.itemForwarded {
@@ -323,6 +331,18 @@ struct FramedItemView: View {
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 {
@@ -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
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 {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared
@@ -32,7 +53,6 @@ struct ChatView: View {
@State private var connectionCode: String?
@State private var loadingItems = false
@State private var firstPage = false
@State private var revealedChatItem: ChatItem?
@State private var searchMode = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@@ -46,6 +66,9 @@ struct ChatView: View {
@State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: 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
@@ -167,6 +190,7 @@ struct ChatView: View {
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
firstPage = false
selectedChatItems = nil
scrollModel.scrollToBottom()
stopAudioPlayer()
@@ -180,7 +204,7 @@ struct ChatView: View {
dismiss()
}
}
.onChange(of: revealedChatItem) { _ in
.onChange(of: revealedItems.count) { _ in
NotificationCenter.postReverseListNeedsLayout()
}
.onChange(of: im.isLoading) { isLoading in
@@ -362,7 +386,18 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false)
}
}
if im.reversedChatItems.count == loadItemsPerPage {
loadChatItems(chat.chatInfo, .last(count: loadItemsPerPage))
}
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
Task {
DispatchQueue.main.async {
if let firstunreadItem = self.getFirstUnreadItem() {
initialChatItem = firstunreadItem
}
}
}
}
private func searchToolbar() -> some View {
@@ -416,9 +451,10 @@ struct ChatView: View {
private func chatItemsList() -> some View {
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
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 maxWidth = cInfo.chatType == .group
? voiceNoFrame
@@ -433,13 +469,19 @@ struct ChatView: View {
maxWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem,
revealedChatItems: $revealedItems,
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
} loadPage: {
loadChatItems(cInfo)
} loadPage: { pagination in
loadChatItems(cInfo, pagination)
}
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
.padding(.vertical, -InvertedTableView.inset)
@@ -471,6 +513,23 @@ struct ChatView: View {
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 {
static let shared = FloatingButtonModel()
@@ -478,17 +537,24 @@ struct ChatView: View {
@Published var isNearBottom: Bool = true
@Published var date: Date?
@Published var isDateVisible: Bool = false
@Published var bottomItemIndex: Int = 0
var totalUnread: Int = 0
var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem?
func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared
let unreadBelow =
let bottomItemIndex =
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
{
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
index
} else {
-1
}
var unreadBelow =
if bottomItemIndex != -1 {
im.reversedChatItems[..<bottomItemIndex].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else {
@@ -508,6 +574,7 @@ struct ChatView: View {
it.unreadBelow = unreadBelow
it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
it.bottomItemIndex = bottomItemIndex
}
// 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 {
if loadingItems || firstPage { return }
if loadingItems { return }
loadingItems = true
do {
var reversedPage = Array<ChatItem>()
var chatItemsAvailable = true
// Load additional items until the page is +50 large after merging
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
let pagination: ChatPagination =
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
} else {
.last(count: loadItemsPerPage)
let chatItems = try await apiGetChatItems(
type: cInfo.chatType,
id: cInfo.apiId,
pagination: pagination,
search: searchText
)
if (cInfo.id != chatModel.chatId) {
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,
id: cInfo.apiId,
pagination: pagination,
search: searchText
)
chatItemsAvailable = !chatItems.isEmpty
reversedPage.append(contentsOf: chatItems.reversed())
}
await MainActor.run {
if reversedPage.count == 0 {
firstPage = true
} else {
im.reversedChatItems.append(contentsOf: reversedPage)
if !duplicateFound {
if let existingItem = im.reversedChatItems.first {
anchors = [existingItem.id]
}
}
newItems.insert(contentsOf: chatItems.reversed(), at: 0)
im.reversedChatItems = newItems
loadingItems = false
}
case .initial:
await MainActor.run {
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 {
logger.error("apiGetChat error: \(responseError(error))")
await MainActor.run { loadingItems = false }
@@ -892,7 +1054,7 @@ struct ChatView: View {
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@Binding var selectedMember: GMember?
@Binding var revealedChatItem: ChatItem?
@Binding var revealedChatItems: Set<ChatItem.ID>
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@@ -907,25 +1069,8 @@ struct ChatView: View {
@State private var allowMenu: Bool = true
@State private var markedRead = false
var revealed: Bool { chatItem == revealedChatItem }
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)
}
}
let onReveal: (Bool) -> Void
var revealed: Bool { revealedChatItems.contains(chatItem.id) }
var body: some View {
let currIndex = m.getChatItemIndex(chatItem)
@@ -933,46 +1078,7 @@ struct ChatView: View {
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared
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 {
func markAsRead() {
if markedRead {
return
} 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] {
@@ -1008,8 +1138,13 @@ struct ChatView: View {
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
Task {
_ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id {
await op()
let currIndex = m.getChatItemIndex(chatItem)
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> {
Button {
withConditionalAnimation {
revealedChatItem = nil
onReveal(false)
}
} label: {
Label(
@@ -1648,7 +1783,7 @@ struct ChatView: View {
private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = ci
onReveal(true)
}
} label: {
Label(
@@ -1661,7 +1796,7 @@ struct ChatView: View {
private func expandButton() -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = chatItem
onReveal(true)
}
} label: {
Label(
@@ -1674,7 +1809,7 @@ struct ChatView: View {
private func shrinkButton() -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = nil
onReveal(false)
}
} 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
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
let groups: SectionGroups
@Binding var scrollState: ReverseListScrollModel.State
@Binding var initialChatItem: ChatItem?
/// 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 {
Controller(representer: self)
@@ -27,18 +28,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
if case let .scrollingTo(destination) = scrollState, !groups.sections.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
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:
controller.scroll(to: 0, position: .top)
}
} else {
controller.update(items: items)
controller.update(groups: groups)
}
}
@@ -46,10 +47,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var dataSource: UITableViewDiffableDataSource<Section, ListItem>!
private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var revealedItems: Array<ListItem> = []
init(representer: ReverseList) {
self.representer = representer
@@ -75,11 +77,16 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
self.dataSource = UITableViewDiffableDataSource<Section, ListItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 {
self.representer.loadPage()
if self.representer.scrollState == .atDestination, self.representer.initialChatItem == nil {
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)
if #available(iOS 16.0, *) {
@@ -149,6 +156,29 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
tableView.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
func scrollToNextPage() {
@@ -169,6 +199,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
@@ -181,18 +212,49 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: animated
)
}
Task { representer.scrollState = .atDestination }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
self.representer.scrollState = .atDestination
}
}
}
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
func update(groups: SectionGroups) {
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.appendItems(items)
snapshot.appendItems(revealedItems, toSection: .main)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
let countDiff = max(0, revealedItems.count - itemCount)
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
if itemCount == 0 {
tableView.setContentOffset(
@@ -200,7 +262,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: false
)
}
itemCount = items.count
itemCount = revealedItems.count
updateFloatingButtons.send()
}
@@ -210,17 +272,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func getListState() -> ListState? {
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 topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs
revealedItems[lastVisible.item].item.meta.itemTs
} else {
nil
}
let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id
revealedItems[firstVisible.item].item.id
} else {
nil
}
@@ -238,6 +301,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
}
private func getFirstItemAfterPlacholder(_ indexPath: IndexPath) -> ChatItem? {
return nil
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
@@ -200,6 +200,7 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.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 */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.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>"; };
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>"; };
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>"; };
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>"; };
@@ -734,6 +736,7 @@
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */,
);
path = Chat;
sourceTree = "<group>";
@@ -1483,6 +1486,7 @@
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
+5 -1
View File
@@ -1133,12 +1133,16 @@ public enum ChatPagination {
case last(count: Int)
case after(chatItemId: Int64, count: Int)
case before(chatItemId: Int64, count: Int)
case around(chatItemId: Int64, count: Int)
case initial(count: Int)
var cmdString: String {
switch self {
case let .last(count): return "count=\(count)"
case let .after(chatItemId, count): return "after=\(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
return item
}
public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem {
ChatItem(
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? {
val cInfo = chat.chatInfo
// val composedMessages = Array(300) { index ->
// ComposedMessage(
// file,
// quoted,
// MsgContent.MCText("$index")
// )
// }.toList()
val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItems(
rh = chat.remoteHostId,
@@ -605,7 +605,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi
listGroups count pending =
readTVarIO (groupRegs st) >>= \groups -> do
grs <-
if pending
if pending
then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups
else pure groups
sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "")
+1
View File
@@ -8302,6 +8302,7 @@ chatCommandP =
<|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPAround <$ "around=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPInitial <$ "initial=" <*> A.decimal)
paginationByTimeP =
(PTLast <$ "count=" <*> A.decimal)
<|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal)
+1
View File
@@ -840,6 +840,7 @@ data ChatPagination
| CPAfter ChatItemId Int
| CPBefore ChatItemId Int
| CPAround ChatItemId Int
| CPInitial Int
deriving (Show)
data PaginationByTime
+182 -57
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
@@ -956,29 +957,32 @@ getDirectChat db vr user contactId pagination search_ = do
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId 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)
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do
getDirectChatLast_ db user ct count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsLast_
chatItemIds <- getDirectChatItemIdsLast_ db user ct count search
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemIdsLast_ :: IO [ChatItemId]
getDirectChatItemIdsLast_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, count)
getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId]
getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, count)
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId =
@@ -1078,10 +1082,40 @@ getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search =
beforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetDirectItem db user ct currentTs) beforeIds
beforeChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (DirectChat ct) chatItems stats
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
getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getDirectChatMinUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status = ?
|]
(userId, contactId, CISRcvNew)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChat db vr user groupId pagination search_ = do
@@ -1092,28 +1126,31 @@ getGroupChat db vr user groupId pagination search_ = do
CPAfter afterId count -> getGroupChatAfter_ db user g afterId 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 user@User {userId} g@GroupInfo {groupId} count search = do
getGroupChatLast_ db user g count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getGroupChatItemIdsLast_
chatItemIds <- getGroupChatItemIdsLast_ db user g count search
currentTs <- getCurrentTime
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId]
getGroupChatItemIdsLast_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|]
(userId, groupId, search, count)
getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId]
getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|]
(userId, groupId, search, count)
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId =
@@ -1213,10 +1250,40 @@ getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
beforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g aroundItemId fetchCountBefore search (chatItemTs middleChatItem)
afterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g aroundItemId fetchCountAfter search (chatItemTs middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetGroupItem db user g currentTs) beforeIds
beforeChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (GroupChat g) chatItems stats
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
getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getGroupChatMinUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_status = ?
|]
(userId, groupId, CISRcvNew)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChat db user folderId pagination search_ = do
@@ -1227,28 +1294,31 @@ getLocalChat db user folderId pagination search_ = do
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId 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 user@User {userId} nf@NoteFolder {noteFolderId} count search = do
getLocalChatLast_ db user nf count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsLast_
chatItemIds <- getLocalChatItemIdsLast_ db user nf count search
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsLast_ :: IO [ChatItemId]
getLocalChatItemIdsLast_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, noteFolderId, search, count)
getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId]
getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, noteFolderId, search, count)
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
@@ -1332,10 +1402,49 @@ getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count sear
beforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetLocalItem db user nf currentTs) beforeIds
beforeChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) afterIds
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
pure $ Chat (LocalChat nf) chatItems stats
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
getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getLocalChatMinUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
|]
(userId, noteFolderId, CISRcvNew)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case
@@ -1628,6 +1737,12 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId
CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId
CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
firstUnreadItemId <- liftIO getFirstUnreadItemId_
case firstUnreadItemId of
Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId
Nothing -> liftIO $ getAllChatItemsLast_ count
mapM (uncurry (getAChatItem db vr user)) itemRefs
where
search = fromMaybe "" search_
@@ -1686,6 +1801,16 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
item <- getChatItem aroundId
itemsAfter <- getAllChatItemsAfter_ aroundId fetchCountAfter aroundTs
pure $ itemsBefore <> item <> itemsAfter
getFirstUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(chat_item_id)
FROM chat_items
WHERE user_id = ? AND item_status = ?
|]
(userId, CISRcvNew)
getChatItemIdsByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO [ChatItemId]
getChatItemIdsByAgentMsgId db connId msgId =
+44
View File
@@ -66,6 +66,7 @@ chatDirectTests = do
it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact
it "should send multiline message" testMultilineMessage
it "send large message" testLargeMessage
it "initial chat pagination" testChatPaginationInitial
describe "batch send messages" $ do
it "send multiple messages api" testSendMulti
it "send multiple timed messages" testSendMultiTimed
@@ -361,6 +362,49 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
let itemIds = intercalate "," $ map show [i - 3 .. i]
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 =
testChat2 aliceProfile bobProfile $
+46
View File
@@ -36,6 +36,7 @@ chatGroupTests = do
describe "chat groups" $ do
describe "add contacts, create group and send/receive messages" testGroupMatrix
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, check messages" testGroupCheckMessages
it "send large message" testGroupLargeMessage
@@ -375,6 +376,51 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
let itemIds = intercalate "," $ map show [i - 3 .. i]
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 =
testChat2 aliceProfile bobProfile $