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
19 changed files with 752 additions and 387 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
+11 -7
View File
@@ -318,17 +318,19 @@ 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, Int?) {
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat, gap) = r { return (Chat.init(chat), gap) }
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r
}
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> ([ChatItem], Int?) {
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, gap) = r { return (chat.chatItems, gap) }
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 {
@@ -348,9 +350,11 @@ 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)
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
@@ -51,11 +51,11 @@ struct FramedItemView: View {
if let itemId = qi.itemId {
if !scrollToItem(itemId) {
Task {
if await loadItemsAround(chat.chatInfo, itemId) != nil {
await MainActor.run {
let _ = scrollToItem(itemId)
}
}
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
// await MainActor.run {
// let _ = scrollToItem(itemId)
//}
//}
}
}
} else {
@@ -342,31 +342,7 @@ struct FramedItemView: View {
} else {
return false
}
}
private func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) async -> [ChatItem]? {
do {
var reversedPage = Array<ChatItem>()
let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2)
let (chatItems, _) = try await apiGetChatItems(
type: cInfo.chatType,
id: cInfo.apiId,
pagination: pagination,
search: ""
)
reversedPage.append(contentsOf: chatItems.reversed())
await MainActor.run {
ItemsModel.shared.reversedChatItems.append(contentsOf: reversedPage)
}
return reversedPage
} catch let error {
logger.error("apiGetChat error: \(responseError(error))")
return nil
}
}
}
}
@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
}
}
+227 -110
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,10 +386,16 @@ 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 {
if let firstunreadItem = self.getFirstUnreadItem() {
scrollModel.scrollToUnread(id: firstunreadItem.id)
DispatchQueue.main.async {
if let firstunreadItem = self.getFirstUnreadItem() {
initialChatItem = firstunreadItem
}
}
}
}
@@ -421,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
@@ -438,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)
@@ -478,13 +515,17 @@ struct ChatView: View {
}
private func getFirstUnreadItem() -> ChatItem? {
logger.error("[scrolling] \(im.reversedChatItems.count)")
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 {
logger.error("[scrolling] First unread item: \(item.text)")
return item
if item.mergeCategory == nil {
return maybeItem ?? item
} else {
maybeItem = item
}
} else if maybeItem != nil {
return maybeItem
}
}
return nil
@@ -496,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 {
@@ -526,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
@@ -857,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 }
@@ -910,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
@@ -925,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)
@@ -951,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 {
@@ -1009,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] {
@@ -1026,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
}
}
}
@@ -1591,7 +1708,7 @@ struct ChatView: View {
private func hideButton() -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = nil
onReveal(false)
}
} label: {
Label(
@@ -1666,7 +1783,7 @@ struct ChatView: View {
private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = ci
onReveal(true)
}
} label: {
Label(
@@ -1679,7 +1796,7 @@ struct ChatView: View {
private func expandButton() -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = chatItem
onReveal(true)
}
} label: {
Label(
@@ -1692,7 +1809,7 @@ struct ChatView: View {
private func shrinkButton() -> Button<some View> {
Button {
withConditionalAnimation {
revealedChatItem = nil
onReveal(false)
}
} label: {
Label (
@@ -340,7 +340,7 @@ struct GroupMemberInfoView: View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let (chat, _) = try await apiGetChat(type: .direct, id: contactId)
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
+90 -49
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,21 +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)
case let .unread(id):
controller.scrollToUnread(to: items.firstIndex(where: { $0.id == id }))
}
} else {
logger.error("[scrolling] not scrolling")
controller.update(items: items)
controller.update(groups: groups)
}
}
@@ -49,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
@@ -78,12 +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.scrollState == .atDestination {
logger.error("[scrolling] requesting page")
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, *) {
@@ -153,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() {
@@ -165,18 +191,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
)
Task { representer.scrollState = .atDestination }
}
func scrollToUnread(to index: Int?) {
if let index = index {
if isVisible(indexPath: IndexPath(row: index, section: 0)) {
self.scroll(to: 0, position: .top)
} else {
self.scroll(to: index, position: .bottom)
}
} else {
self.scroll(to: index, position: .bottom)
}
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
@@ -185,7 +199,8 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0, !isVisible(indexPath: IndexPath(row: index, section: 0)) {
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
@@ -197,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(
@@ -216,7 +262,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: false
)
}
itemCount = items.count
itemCount = revealedItems.count
updateFloatingButtons.send()
}
@@ -226,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
}
@@ -254,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
@@ -310,7 +361,6 @@ class ReverseListScrollModel: ObservableObject {
case nextPage
case item(ChatItem.ID)
case bottom
case unread(ChatItem.ID)
}
case scrollingTo(Destination)
@@ -320,25 +370,16 @@ class ReverseListScrollModel: ObservableObject {
@Published var state: State = .atDestination
func scrollToNextPage() {
logger.error("[scrolling] to next page")
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
logger.error("[scrolling] to bottom")
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
logger.error("[scrolling] to item \(id)")
state = .scrollingTo(.item(id))
}
func scrollToUnread(id: ChatItem.ID) {
logger.error("[scrolling] to unread")
state = .scrollingTo(.unread(id))
}
}
fileprivate let cellReuseId = "hostingCell"
@@ -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 */,
+1 -2
View File
@@ -214,8 +214,7 @@ public func chatResponse(_ s: String) -> ChatResponse {
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
let jChat = jApiChat["chat"] as? NSDictionary,
let chat = try? parseChatData(jChat) {
let gap = jApiChat["gap"] as? Int
return .apiChat(user: user, chat: chat, gap: gap)
return .apiChat(user: user, chat: chat)
}
} else if type == "chatCmdError" {
if let jError = jResp["chatCmdError"] as? NSDictionary {
+2 -2
View File
@@ -546,7 +546,7 @@ public enum ChatResponse: Decodable, Error {
case chatStopped
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData, gap: Int?)
case apiChat(user: UserRef, chat: ChatData)
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: UserRef, servers: UserProtoServers)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
@@ -888,7 +888,7 @@ public enum ChatResponse: Decodable, Error {
case .chatStopped: return noDetails
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat, gap): return withUser(u, "gap: \(String(describing: gap)) \(String(describing: chat))")
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
+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,
@@ -643,7 +643,7 @@ getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId)
where
resp :: ChatResponse -> Maybe Contact
resp = \case
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct
_ -> Nothing
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
+6 -6
View File
@@ -735,14 +735,14 @@ processChatCommand' vr = \case
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
-- TODO optimize queries calculating ChatStats, currently they're disabled
CTDirect -> do
(directChat, gap) <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTDirect directChat) gap
directChat <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTDirect directChat)
CTGroup -> do
(groupChat, gap) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) gap
groupChat <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat)
CTLocal -> do
(localChat, gap) <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) gap
localChat <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat)
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIGetChatItems pagination search -> withUser $ \user -> do
+1 -6
View File
@@ -572,7 +572,7 @@ data ChatResponse
| CRChatSuspended
| CRApiChats {user :: User, chats :: [AChat]}
| CRChats {chats :: [AChat]}
| CRApiChat {user :: User, chat :: AChat, gap :: Maybe ChatGap}
| CRApiChat {user :: User, chat :: AChat}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
@@ -843,9 +843,6 @@ data ChatPagination
| CPInitial Int
deriving (Show)
data ChatGap = ChatGap {index :: Maybe Int, size :: Int}
deriving (Show)
data PaginationByTime
= PTLast Int
| PTAfter UTCTime Int
@@ -1596,8 +1593,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
$(JQ.deriveJSON defaultJSON ''ChatGap)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
+82 -165
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
@@ -138,7 +139,7 @@ import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Controller (ChatGap (ChatGap, index, size), ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
@@ -947,12 +948,12 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of
aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats
in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChat db vr user contactId pagination search_ = do
let search = fromMaybe "" search_
ct <- getContact db vr user contactId
case pagination of
CPLast count -> liftIO $ (,Nothing) <$> 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
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search
@@ -983,27 +984,6 @@ getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
|]
(userId, contactId, search, count)
getDirectChatItemGapToLatest_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> String -> IO Int
getDirectChatItemGapToLatest_ db User {userId} Contact {contactId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND contact_id = :contactId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":contactId" := contactId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId =
runExceptT (getDirectCIWithReactions db user ct itemId)
@@ -1046,16 +1026,14 @@ getDirectChatItemLast db user@User {userId} contactId = do
(userId, contactId)
getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getDirectChatItem db user contactId afterChatItemId
chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
gapToLatest <- liftIO $ getDirectChatItemGapToLatest_ db user ct (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (DirectChat ct) chatItems stats, chatGap)
pure $ Chat (DirectChat ct) chatItems stats
getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId count search afterChatItemCreatedAt =
@@ -1072,14 +1050,14 @@ getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId
|]
(userId, contactId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
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}
beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure (Chat (DirectChat ct) (reverse chatItems) stats, Nothing)
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 =
@@ -1096,7 +1074,7 @@ getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItem
|]
(userId, contactId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
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)
@@ -1104,37 +1082,29 @@ 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
gapToLatest <- liftIO $ getDirectChatItemGapToLatest_ db user ct (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (DirectChat ct) chatItems stats, chatGap)
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, Maybe ChatGap)
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 -> do
(chat, gap) <- getDirectChatAround_ db user ct firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getDirectChatLast_ db user ct count ""
Just firstUnreadItemId -> getDirectChatAround_ db user ct firstUnreadItemId count ""
Nothing -> liftIO $ getDirectChatLast_ db user ct count ""
where
getLatestItems_ :: Chat 'CTDirect -> Int -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getDirectChatItemIdsLast_ db user ct (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getDirectChatMinUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
@@ -1147,12 +1117,12 @@ getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
|]
(userId, contactId, CISRcvNew)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChat db vr user groupId pagination search_ = do
let search = fromMaybe "" search_
g <- getGroupInfo db vr user groupId
case pagination of
CPLast count -> liftIO $ (,Nothing) <$> 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
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search
@@ -1182,27 +1152,6 @@ getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
|]
(userId, groupId, search, count)
getGroupChatItemGapToLatest_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> String -> IO Int
getGroupChatItemGapToLatest_ db User {userId} GroupInfo {groupId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND group_id = :groupId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":groupId" := groupId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId =
runExceptT (getGroupCIWithReactions db user g itemId)
@@ -1245,16 +1194,14 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
(userId, groupId, groupMemberId)
getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ db user g afterChatItemId count search (chatItemTs afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
gapToLatest <- liftIO $ getGroupChatItemGapToLatest_ db user g (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (GroupChat g) chatItems stats, chatGap)
pure $ Chat (GroupChat g) chatItems stats
getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
@@ -1271,14 +1218,14 @@ getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId c
|]
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ db user g beforeChatItemId count search (chatItemTs beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure (Chat (GroupChat g) (reverse chatItems) stats, Nothing)
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 =
@@ -1295,7 +1242,7 @@ getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId
|]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
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)
@@ -1303,26 +1250,28 @@ 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
gapToLatest <- liftIO $ getGroupChatItemGapToLatest_ db user g (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (GroupChat g) chatItems stats, chatGap)
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, Maybe ChatGap)
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 -> do
(chat, gap) <- getGroupChatAround_ db user g firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g count ""
Just firstUnreadItemId -> getGroupChatAround_ db user g firstUnreadItemId count ""
Nothing -> liftIO $ getGroupChatLast_ db user g count ""
where
getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getGroupChatMinUnreadItemId_ =
@@ -1335,23 +1284,13 @@ getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
WHERE user_id = ? AND group_id = ? AND item_status = ?
|]
(userId, groupId, CISRcvNew)
getLatestItems_ :: Chat 'CTGroup -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getGroupChatItemIdsLast_ db user g (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId
case pagination of
CPLast count -> liftIO $ (,Nothing) <$> 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
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search
@@ -1381,27 +1320,6 @@ getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search
|]
(userId, noteFolderId, search, count)
getLocalChatItemGapToLatest_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> String -> IO Int
getLocalChatItemGapToLatest_ db User {userId} NoteFolder {noteFolderId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND note_folder_id = :noteFolderId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":noteFolderId" := noteFolderId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
runExceptT (getLocalChatItem db user noteFolderId itemId)
@@ -1428,16 +1346,14 @@ safeToLocalItem currentTs itemId = \case
file = Nothing
}
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
gapToLatest <- liftIO $ getLocalChatItemGapToLatest_ db user nf (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (LocalChat nf) chatItems stats, chatGap)
pure $ Chat (LocalChat nf) chatItems stats
getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatItemId count search afterChatItemCreatedAt =
@@ -1454,14 +1370,14 @@ getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatIt
|]
(userId, noteFolderId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
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}
beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure (Chat (LocalChat nf) (reverse chatItems) stats, Nothing)
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 =
@@ -1478,7 +1394,7 @@ getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChat
|]
(userId, noteFolderId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
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)
@@ -1486,26 +1402,37 @@ 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
gapToLatest <- liftIO $ getLocalChatItemGapToLatest_ db user nf (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (LocalChat nf) chatItems stats, chatGap)
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, Maybe ChatGap)
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, gap) <- getLocalChatAround_ db user nf firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getLocalChatLast_ db user nf count ""
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_ =
@@ -1518,16 +1445,6 @@ getLocalChatInitial_ db user@User {userId} nf@NoteFolder {noteFolderId} count =
WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
|]
(userId, noteFolderId, CISRcvNew)
getLatestItems_ :: Chat 'CTLocal -> Int -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getLocalChatItemIdsLast_ db user nf (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case
+1 -7
View File
@@ -93,7 +93,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChatSuspended -> ["chat suspended"]
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
CRChats chats -> viewChats ts tz chats
CRApiChat u chat gap -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] <> viewChatGap gap
CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
@@ -555,12 +555,6 @@ viewUsersList us =
viewGroupSubscribed :: GroupInfo -> [StyledString]
viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"]
viewChatGap :: Maybe ChatGap -> [StyledString]
viewChatGap Nothing = []
viewChatGap (Just ChatGap {size})
| size < 1 = []
| otherwise = [sShow size <> " newer message(s) available"]
showSMPServer :: SMPServer -> String
showSMPServer ProtocolServer {host} = B.unpack $ strEncode host
+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 $