diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6b6b0ac03f..06a6a673a1 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -53,7 +53,11 @@ class ItemsModel: ObservableObject { var itemAdded = false { willSet { publisher.send() } } - + + // set listener here that will be notified on every add/delete of a chat item + var chatItemsChangesListener: ChatItemsChangesListener? = nil + let chatState = ActiveChatState() + // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false @@ -82,19 +86,19 @@ class ItemsModel: ObservableObject { await MainActor.run { showLoadingProgress = true } } catch {} } + let type = ChatType(rawValue: String(chatId.prefix(1)))! + let id = Int64(chatId.suffix(chatId.count - 1))! Task { - if let chat = ChatModel.shared.getChat(chatId) { - await MainActor.run { self.isLoading = true } -// try? await Task.sleep(nanoseconds: 5000_000000) - await loadChat(chat: chat) - navigationTimeout.cancel() - progressTimeout.cancel() - await MainActor.run { - self.isLoading = false - self.showLoadingProgress = false - willNavigate() - ChatModel.shared.chatId = chatId - } + await MainActor.run { self.isLoading = true } + // try? await Task.sleep(nanoseconds: 5000_000000) + await loadChat(type: type, id: id) + navigationTimeout.cancel() + progressTimeout.cancel() + await MainActor.run { + self.isLoading = false + self.showLoadingProgress = false + willNavigate() +// ChatModel.shared.chatId = id } } } @@ -897,6 +901,15 @@ final class ChatModel: ObservableObject { } } +protocol ChatItemsChangesListener { + // pass null itemIds if the whole chat now read + func read(_ itemIds: Set?, _ newItems: [ChatItem]) + func added(_ item: (Int64, Bool), _ index: Int) + // itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not) + func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) + func cleared() +} + struct ShowingInvitation { var pcc: PendingContactConnection var connChatUsed: Bool diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c03483311d..05311c803b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -320,35 +320,34 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { let loadItemsPerPage = 50 -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)) - if case let .apiChat(_, chat) = r { return Chat.init(chat) } +func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { + let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) + if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo) } throw r } 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 let .apiChat(_, chat, _) = r { return chat.chatItems } throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { - do { - let cInfo = chat.chatInfo +func loadChat(type: ChatType, id: Int64, search: String = "", clearItems: Bool = true) async { +// do { let m = ChatModel.shared let im = ItemsModel.shared m.chatItemStatuses = [:] if clearItems { await MainActor.run { im.reversedChatItems = [] } } - 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) - } - } catch let error { - logger.error("loadChat error: \(responseError(error))") - } + /*try */await apiLoadMessages(type, id, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 }) +// await MainActor.run { +// im.reversedChatItems = chat.chatItems.reversed() +// m.updateChatInfo(chat.chatInfo) +// } +// } catch let error { +// logger.error("loadChat error: \(responseError(error))") +// } } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 0dd54782b4..9245b7d39f 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -144,7 +144,7 @@ struct SimpleXApp: App { await MainActor.run { chatModel.updateChats(chats) } if let id = chatModel.chatId, let chat = chatModel.getChat(id) { - Task { await loadChat(chat: chat, clearItems: false) } + Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, clearItems: false) } } if let ncr = chatModel.ntfContactRequest { await MainActor.run { chatModel.ntfContactRequest = nil } diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift new file mode 100644 index 0000000000..5064a3d757 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -0,0 +1,329 @@ +// +// ChatItemsLoader.swift +// SimpleX (iOS) +// +// Created by me on 17.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SimpleXChat +import SwiftUI + +let TRIM_KEEP_COUNT = 200 + +func apiLoadMessages( + _ chatType: ChatType, + _ apiId: Int64, + _ pagination: ChatPagination, + _ chatState: ActiveChatState, + _ search: String = "", + _ visibleItemIndexesNonReversed: () -> ClosedRange = { 0 ... 0 } +) async { + let chat: Chat + let navInfo: NavigationInfo + do { + (chat, navInfo) = try await apiGetChat(type: chatType, id: apiId, pagination: pagination, search: search) + } catch let error { + logger.error("apiLoadMessages error: \(responseError(error))") + return + } + + let chatModel = ChatModel.shared + + // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes + let paginationIsInitial = switch pagination { case .initial: true; default: false } + let paginationIsLast = switch pagination { case .last: true; default: false } + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) { + return + } + + let unreadAfterItemId = chatState.unreadAfterItemId + + let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed()) + var newItems: [ChatItem] = [] + switch pagination { + case .initial: + let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] } + if chatModel.getChat(chat.id) == nil { + chatModel.addChat(chat) + } + await MainActor.run { + chatModel.chatItemStatuses.removeAll() + ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() + chatModel.updateChatInfo(chat.chatInfo) + chatModel.chatId = chat.chatInfo.id + chatState.splits = newSplits + if !chat.chatItems.isEmpty { + chatState.unreadAfterItemId = chat.chatItems.last!.id + } + chatState.totalAfter = navInfo.afterTotal + chatState.unreadTotal = chat.chatStats.unreadCount + chatState.unreadAfter = navInfo.afterUnread + chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } + case let .before(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + let (newIds, _) = mapItemsToIds(chat.chatItems) + let wasSize = newItems.count + let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination( + unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexesNonReversed + ) + let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0) + newItems.insert(contentsOf: chat.chatItems, at: insertAt) + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + ItemsModel.shared.reversedChatItems = newReversed + chatState.splits = modifiedSplits.newSplits + chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) + } + case let .after(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + + let mappedItems = mapItemsToIds(chat.chatItems) + let newIds = mappedItems.0 + let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( + mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits + ) + let indexToAdd = min(indexInCurrentItems + 1, newItems.count) + let indexToAddIsLast = indexToAdd == newItems.count + newItems.insert(contentsOf: chat.chatItems, at: indexToAdd) + let new: [ChatItem] = newItems + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + ItemsModel.shared.reversedChatItems = newReversed + chatState.splits = newSplits + chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new) + // loading clear bottom area, updating number of unread items after the newest loaded item + if indexToAddIsLast { + chatState.unreadAfterNewestLoaded -= unreadInLoaded + } + } + case .around: + newItems.append(contentsOf: oldItems) + let newSplits = removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + // currently, items will always be added on top, which is index 0 + newItems.insert(contentsOf: chat.chatItems, at: 0) + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + ItemsModel.shared.reversedChatItems = newReversed + chatState.splits = [chat.chatItems.last!.id] + newSplits + chatState.unreadAfterItemId = chat.chatItems.last!.id + chatState.totalAfter = navInfo.afterTotal + chatState.unreadTotal = chat.chatStats.unreadCount + chatState.unreadAfter = navInfo.afterUnread + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } + case .last: + newItems.append(contentsOf: oldItems) + removeDuplicates(&newItems, chat) + newItems.append(contentsOf: chat.chatItems) + let items = newItems + await MainActor.run { + ItemsModel.shared.reversedChatItems = items.reversed() + chatModel.updateChatInfo(chat.chatInfo) + chatState.unreadAfterNewestLoaded = 0 + } + } +} + + +private class ModifiedSplits { + let oldUnreadSplitIndex: Int + let newUnreadSplitIndex: Int + let trimmedIds: Set + let newSplits: [Int64] + + init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set, newSplits: [Int64]) { + self.oldUnreadSplitIndex = oldUnreadSplitIndex + self.newUnreadSplitIndex = newUnreadSplitIndex + self.trimmedIds = trimmedIds + self.newSplits = newSplits + } +} + +private func removeDuplicatesAndModifySplitsOnBeforePagination( + _ unreadAfterItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ splits: [Int64], + _ visibleItemIndexesNonReversed: () -> ClosedRange +) -> ModifiedSplits { + var oldUnreadSplitIndex: Int = -1 + var newUnreadSplitIndex: Int = -1 + let visibleItemIndexes = visibleItemIndexesNonReversed() + var lastSplitIndexTrimmed: Int? = nil + var allowedTrimming = true + var index = 0 + /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ + let trimRange = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT ... newItems.count - TRIM_KEEP_COUNT + var trimmedIds = Set() + let prevItemTrimRange = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1 ... newItems.count - TRIM_KEEP_COUNT + var newSplits = splits + + newItems.removeAll(where: { + let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming + let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming + // may disable it after clearing the whole split range + if !splits.isEmpty && $0.id == splits.first { + // trim only in one split range + allowedTrimming = false + } + let indexInSplits = splits.firstIndex(of: $0.id) + if let indexInSplits { + lastSplitIndexTrimmed = indexInSplits + } + if invisibleItemToTrim { + if prevItemWasTrimmed { + trimmedIds.insert($0.id) + } else { + newUnreadSplitIndex = index + // prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead. + // this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction + if let lastSplitIndexTrimmed { + var new = newSplits + new[lastSplitIndexTrimmed] = $0.id + newSplits = new + } else { + newSplits = [$0.id] + newSplits + } + } + } + if unreadAfterItemId == $0.id { + oldUnreadSplitIndex = index + } + index += 1 + return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id) + }) + // will remove any splits that now becomes obsolete because items were merged + newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) } + return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits) +} + +private func removeDuplicatesAndModifySplitsOnAfterPagination( + _ unreadInLoaded: Int, + _ paginationChatItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ chat: Chat, + _ splits: [Int64] +) -> ([Int64], Int) { + var unreadInLoaded = unreadInLoaded + var firstItemIdBelowAllSplits: Int64? = nil + var splitsToRemove: [Int64] = [] + let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId) + // Currently, it should always load from split range + let loadingFromSplitRange = indexInSplitRanges != nil + var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { + Array(splits[indexInSplitRanges + 1 ..< splits.count]) + } else { + [] + } + newItems.removeAll(where: { new in + let duplicate = newIds.contains(new.id) + if loadingFromSplitRange && duplicate { + if splitsToMerge.contains(new.id) { + splitsToMerge.removeAll(where: { $0 == new.id }) + splitsToRemove.append(new.id) + } else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty { + // we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items + firstItemIdBelowAllSplits = new.id + } + } + if duplicate && new.isRcvNew { + unreadInLoaded -= 1 + } + return duplicate + }) + var newSplits: [Int64] = [] + if firstItemIdBelowAllSplits != nil { + // no splits anymore, all were merged with bottom items + newSplits = [] + } else { + if !splitsToRemove.isEmpty { + var new = splits + // LALAL TODO make it set + new.removeAll(where: { splitsToRemove.contains($0) }) + newSplits = new + } + let enlargedSplit = splits.firstIndex(of: paginationChatItemId) + if let enlargedSplit { + // move the split to the end of loaded items + var new = splits + new[enlargedSplit] = chat.chatItems.last!.id + newSplits = new + // Log.d(TAG, "Enlarged split range $newSplits") + } + } + return (newSplits, unreadInLoaded) +} + +private func removeDuplicatesAndUpperSplits( + _ newItems: inout [ChatItem], + _ chat: Chat, + _ splits: [Int64], + _ visibleItemIndexesNonReversed: () -> ClosedRange +) -> [Int64] { + if splits.isEmpty { + removeDuplicates(&newItems, chat) + return splits + } + + var newSplits = splits + let visibleItemIndexes = visibleItemIndexesNonReversed() + let (newIds, _) = mapItemsToIds(chat.chatItems) + var idsToTrim: [BoxedValue>] = [] + idsToTrim.append(BoxedValue(Set())) + var index = 0 + newItems.removeAll(where: { + let duplicate = newIds.contains($0.id) + if (!duplicate && visibleItemIndexes.lowerBound > index) { + idsToTrim.last?.boxedValue.insert($0.id) + } + let firstIndex = splits.firstIndex(of: $0.id) + if visibleItemIndexes.lowerBound > index, let firstIndex { + newSplits.remove(at: firstIndex) + // closing previous range. All items in idsToTrim that ends with empty set should be deleted. + // Otherwise, the last set should be excluded from trimming because it is in currently visible split range + idsToTrim.append(BoxedValue(Set())) + } + + index += 1 + return duplicate + }) + if !idsToTrim.last!.boxedValue.isEmpty { + // it has some elements to trim from currently visible range which means the items shouldn't be trimmed + // Otherwise, the last set would be empty + idsToTrim.removeLast() + } + let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined() + if !allItemsToDelete.isEmpty { + newItems.removeAll(where: { allItemsToDelete.contains($0.id) }) + } + return newSplits +} + +// ids, number of unread items +private func mapItemsToIds(_ items: [ChatItem]) -> (Set, Int) { + var unreadInLoaded = 0 + var ids: Set = Set() + var i = 0 + while i < items.count { + let item = items[i] + ids.insert(item.id) + if item.isRcvNew { + unreadInLoaded += 1 + } + i += 1 + } + return (ids, unreadInLoaded) +} + +private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) { + let (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { newIds.contains($0.id) } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift new file mode 100644 index 0000000000..c0b3f94d90 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -0,0 +1,416 @@ +// +// ChatItemsMerger.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 02.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MergedItems { + let items: [MergedItem] + let splits: [SplitRange] + // chat item id, index in list + let indexInParentItems: Dictionary + + static func create(_ items: [ChatItem], _ unreadCount: Binding, _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { + if items.isEmpty { + return MergedItems(items: [], splits: [], indexInParentItems: [:]) + } + + let unreadAfterItemId = chatState.unreadAfterItemId + let itemSplits = chatState.splits + var mergedItems: [MergedItem] = [] + // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems + var splitRanges: [SplitRange] = [] + var indexInParentItems = Dictionary() + var index = 0 + var unclosedSplitIndex: Int? = nil + var unclosedSplitIndexInParent: Int? = nil + var visibleItemIndexInParent = -1 + var unreadBefore = unreadCount.wrappedValue - chatState.unreadAfterNewestLoaded + var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil + var lastRangeInReversedForMergedItems: BoxedValue>? = nil + var recent: MergedItem? = nil + while index < items.count { + let item = items[index] + let prev = index >= 1 ? items[index - 1] : nil + let next = index + 1 < items.count ? items[index + 1] : nil + let category = item.mergeCategory + let itemIsSplit = itemSplits.contains(item.id) + + if item.id == unreadAfterItemId { + unreadBefore = unreadCount.wrappedValue - chatState.unreadAfter + } + if item.isRcvNew { + unreadBefore -= 1 + } + + let revealed = item.mergeCategory == nil || revealedItems.contains(item.id) + if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _) = recent, mergeCategory == category, let first = items.first, !revealedItems.contains(first.item.id) && !itemIsSplit { + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + recent!.appendItem(listItem) + + if item.isRcvNew { + recent!.insertUnreadId(item.id) + } + if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems { + if revealed { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index + } + } else { + visibleItemIndexInParent += 1 + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + if item.mergeCategory != nil { + if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil { + lastRevealedIdsInMergedItems?.boxedValue = revealedItems.contains(item.id) ? [item.id] : [] + } else if revealed, var lastRevealedIdsInMergedItems { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems?.boxedValue = index ... index + recent = MergedItem.grouped( + items: [listItem], + revealed: revealed, + revealedIdsWithinGroup: lastRevealedIdsInMergedItems!, + rangeInReversed: lastRangeInReversedForMergedItems!, + mergeCategory: item.mergeCategory, + unreadIds: item.isRcvNew ? Set(arrayLiteral: item.id) : Set(), + startIndexInReversedItems: index + ) + } else { + lastRangeInReversedForMergedItems = nil + recent = MergedItem.single( + item: listItem, + startIndexInReversedItems: index + ) + } + mergedItems.append(recent!) + } + if itemIsSplit { + // found item that is considered as a split + if let unclosedSplitIndex, let unclosedSplitIndexInParent { + // it was at least second split in the list + splitRanges.append(SplitRange(indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) + } + unclosedSplitIndex = index + unclosedSplitIndexInParent = visibleItemIndexInParent + } else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { + // just one split for the whole list, there will be no more, it's the end + splitRanges.append(SplitRange(indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) + } + indexInParentItems[item.id] = visibleItemIndexInParent + index += 1 + } + return MergedItems( + items: mergedItems, + splits: splitRanges, + indexInParentItems: indexInParentItems + ) + } +} + + +enum MergedItem { + // the item that is always single, cannot be grouped and always revealed + case single( + item: ListItem, + startIndexInReversedItems: Int + ) + + /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed, + * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance + * of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of + * visible rows in ChatView LazyColumn */ + case grouped ( + items: [ListItem], + revealed: Bool, + // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action + // it's the same list instance for all Grouped items within revealed group + /** @see reveal */ + revealedIdsWithinGroup: BoxedValue<[Int64]>, + rangeInReversed: BoxedValue>, + mergeCategory: CIMergeCategory?, + unreadIds: Set, + startIndexInReversedItems: Int + ) + + mutating func appendItem(_ item: ListItem) { + switch self { + case let .grouped(items, revealed, revealedIdsWithinGroup, rangeInReversed, mergeCategory, unreadIds, startIndexInReversedItems): + var newItems = items + newItems.append(item) + self = .grouped(items: newItems, revealed: revealed, revealedIdsWithinGroup: revealedIdsWithinGroup, rangeInReversed: rangeInReversed, mergeCategory: mergeCategory, unreadIds: unreadIds, startIndexInReversedItems: startIndexInReversedItems) + case .single: () + } + } + + mutating func insertUnreadId(_ id: Int64) { + switch self { + case let .grouped(items, revealed, revealedIdsWithinGroup, rangeInReversed, mergeCategory, unreadIds, startIndexInReversedItems): + var newUnreadIds = unreadIds + newUnreadIds.insert(id) + self = .grouped(items: items, revealed: revealed, revealedIdsWithinGroup: revealedIdsWithinGroup, rangeInReversed: rangeInReversed, mergeCategory: mergeCategory, unreadIds: newUnreadIds, startIndexInReversedItems: startIndexInReversedItems) + case .single: () + } + } + + func reveal(reveal: Bool, revealedItems: Binding>) { + if case .grouped(let items, let revealed, var revealedIdsWithinGroup, let rangeInReversed, let mergeCategory, let unreadIds, let startIndexInReversedItems) = self { + var newRevealed = revealedItems.wrappedValue + var i = 0 + if reveal { + while i < items.count { + newRevealed.insert(items[i].item.id) + i += 1 + } + } else { + while i < revealedIdsWithinGroup.boxedValue.count { + newRevealed.remove(revealedIdsWithinGroup.boxedValue[i]) + i += 1 + } + revealedIdsWithinGroup.boxedValue.removeAll() + } + revealedItems.wrappedValue = newRevealed + } + } + + var startIndexInReversedItems: Int { + get { + switch self { + case let .single(_, startIndexInReversedItems): startIndexInReversedItems + case let .grouped(_, _, _, _, _, _, startIndexInReversedItems): startIndexInReversedItems + } + } + } + + func hasUnread() -> Bool { + switch self { + case let .single(item, _): item.item.isRcvNew + case let .grouped(_, _, _, _, _, unreadIds, _): !unreadIds.isEmpty + } + } + + func newest() -> ListItem { + switch self { + case let .single(item, _): item + case let .grouped(items, _, _, _, _, _, _): items[0] + } + } + + func oldest() -> ListItem { + switch self { + case let .single(item, _): item + case let .grouped(items, _, _, _, _, _, _): items[items.count - 1] + } + } + + func lastIndexInReversed() -> Int { + switch self { + case .single: startIndexInReversedItems + case let .grouped(items, _, _, _, _, _, _): startIndexInReversedItems + items.count - 1 + } + } +} + +struct SplitRange { + /** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first]) + * so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance + * (3, 4 indexes of the splitRange with the split itself at index 3) + * */ + let indexRangeInReversed: ClosedRange + /** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */ + let indexRangeInParentItems: ClosedRange +} + +struct ListItem { + let item: ChatItem + let prevItem: ChatItem? + let nextItem: ChatItem? + // how many unread items before (older than) this one (excluding this one) + let unreadBefore: Int +} + +class ActiveChatState { + var splits: [Int64] = [] + var unreadAfterItemId: Int64 = -1 + // total items after unread after item (exclusive) + var totalAfter: Int = 0 + var unreadTotal: Int = 0 + // exclusive + var unreadAfter: Int = 0 + // exclusive + var unreadAfterNewestLoaded: Int = 0 + + func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) { + guard let toItemId else { return } + let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId }) + let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId }) + guard let currentIndex, let newIndex else { + return unreadAfterItemId = toItemId + } + let unreadDiff = newIndex > currentIndex + ? -nonReversedItems[currentIndex + 1.. fromIndex + ? -nonReversedItems[fromIndex + 1.. { + var boxedValue : T + init(_ value: T) { + self.boxedValue = value + } +} + +extension ReverseList.Controller { + func visibleItemIndexesNonReversed(_ mergedItems: Binding, _ scrollModel: ReverseListScrollModel) -> ClosedRange { + let zero = 0 ... 0 + if itemCount == 0 { + return zero + } + let listState = getListState() ?? ListState() + let items = mergedItems.wrappedValue.items + let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil + let oldest = items.count > listState.firstVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil + guard let newest, let oldest else { + return zero + } + let size = ItemsModel.shared.reversedChatItems.count + let range = size - oldest ... size - newest + if range.lowerBound < 0 || range.upperBound < 0 { + return zero + } + + // visible items mapped to their underlying data structure which is chatModel.chatItems + return range + } +} + +func recalculateChatStatePositions(_ chatState: ActiveChatState) -> ChatItemsChangesListener { + RecalculatePositions(chatState: chatState) +} + +private class RecalculatePositions: ChatItemsChangesListener { + private let chatState: ActiveChatState + + init(chatState: ActiveChatState) { + self.chatState = chatState + } + + func read(_ itemIds: Set?, _ newItems: [ChatItem]) { + guard let itemIds else { + // special case when the whole chat became read + chatState.unreadTotal = 0 + chatState.unreadAfter = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.count - 1 + var ids = itemIds + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = chatState.unreadTotal + var newUnreadAfter = chatState.unreadAfter + while i >= 0 { + let item = newItems[i] + if item.id == chatState.unreadAfterItemId { + unreadAfterItemIndex = i + } + if ids.contains(item.id) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter -= 1 + } + newUnreadTotal -= 1 + ids.remove(item.id) + if ids.isEmpty { + break + } + } + i -= 1 + } + chatState.unreadTotal = newUnreadTotal + chatState.unreadAfter = newUnreadAfter + } + func added(_ item: (Int64, Bool), _ index: Int) { + if item.1 { + chatState.unreadAfter += 1 + chatState.unreadTotal += 1 + } + } + func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { + var newSplits: [Int64] = [] + for split in chatState.splits { + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) + // deleted the item that was right before the split between items, find newer item so it will act like the split + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + let newSplit = newItems.count > idx ? newItems[idx].id : nil + // it the whole section is gone and splits overlap, don't add it at all + if let newSplit, !newSplits.contains(newSplit) { + newSplits.append(newSplit) + } + } else { + newSplits.append(split) + } + } + chatState.splits = newSplits + + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId }) + // unread after item was removed + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + var newUnreadAfterItemId = newItems.count > idx ? newItems[idx].id : nil + let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil + if newUnreadAfterItemId == nil { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.first?.id + } + if let newUnreadAfterItemId { + chatState.unreadAfterItemId = newUnreadAfterItemId + chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count + chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count + chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count + if newUnreadAfterItemWasNull { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if newItems.first?.isRcvNew == true { + chatState.unreadTotal += 1 + chatState.unreadAfter -= 1 + } + } + } else { + // all items were deleted, 0 items in chatItems + chatState.unreadAfterItemId = -1 + chatState.totalAfter = 0 + chatState.unreadTotal = 0 + chatState.unreadAfter = 0 + } + } else { + chatState.totalAfter -= itemIds.count + } + } + func cleared() { chatState.clear() } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 6b287d52a1..f3c7574306 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -389,7 +389,7 @@ struct ChatView: View { searchText = "" searchMode = false searchFocussed = false - Task { await loadChat(chat: chat) } + Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) } } } .padding(.horizontal) @@ -445,7 +445,7 @@ struct ChatView: View { .padding(.vertical, -InvertedTableView.inset) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { _ in - Task { await loadChat(chat: chat, search: searchText) } + Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, search: searchText) } } .onChange(of: im.itemAdded) { added in if added { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ed40c0592b..1b3d1002fe 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -339,14 +339,8 @@ struct GroupMemberInfoView: View { func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { InfoViewButton(image: "message.fill", title: "message", width: width) { Task { - do { - let chat = try await apiGetChat(type: .direct, id: contactId) - chatModel.addChat(chat) - ItemsModel.shared.loadOpenChat(chat.id) { - dismissAllSheets(animated: true) - } - } catch let error { - logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") + ItemsModel.shared.loadOpenChat("@\(contactId)") { + dismissAllSheets(animated: true) } } } @@ -360,8 +354,7 @@ struct GroupMemberInfoView: View { let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) await MainActor.run { progressIndicator = false - chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) - ItemsModel.shared.loadOpenChat(memberContact.id) { + ItemsModel.shared.loadOpenChat("@\(memberContact.id)") { dismissAllSheets(animated: true) } NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index e33adcef58..187cbb2a38 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -43,11 +43,11 @@ struct ReverseList: UIViewControllerRepresentable { } /// Controller, which hosts SwiftUI cells - class Controller: UITableViewController { + public class Controller: UITableViewController { private enum Section { case main } var representer: ReverseList private var dataSource: UITableViewDiffableDataSource! - private var itemCount: Int = 0 + var itemCount: Int = 0 private let updateFloatingButtons = PassthroughSubject() private var bag = Set() @@ -218,13 +218,15 @@ struct ReverseList: UIViewControllerRepresentable { } else { nil } + let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) + let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) let bottomItemId: ChatItem.ID? = - if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { + if let firstVisible { representer.items[firstVisible.item].id } else { nil } - return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId) + return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.item ?? 0, lastVisibleItemIndex: lastVisible?.item ?? 0) } return nil } @@ -280,11 +282,21 @@ struct ReverseList: UIViewControllerRepresentable { } } -typealias ListState = ( - scrollOffset: Double, - topItemDate: Date?, - bottomItemId: ChatItem.ID? -) +class ListState { + let scrollOffset: Double + let topItemDate: Date? + let bottomItemId: ChatItem.ID? + let firstVisibleItemIndex: Int + let lastVisibleItemIndex: Int + + init(scrollOffset: Double = 0, topItemDate: Date? = nil, bottomItemId: ChatItem.ID? = nil, firstVisibleItemIndex: Int = 0, lastVisibleItemIndex: Int = 0) { + self.scrollOffset = scrollOffset + self.topItemDate = topItemDate + self.bottomItemId = bottomItemId + self.firstVisibleItemIndex = firstVisibleItemIndex + self.lastVisibleItemIndex = lastVisibleItemIndex + } +} /// Manages ``ReverseList`` scrolling class ReverseListScrollModel: ObservableObject { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0ffe9d1f40..5138d36f9e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -199,6 +199,8 @@ 8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; }; 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; }; 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; }; + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; }; 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; }; 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; @@ -549,6 +551,8 @@ 8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = ""; }; + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = ""; }; 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; @@ -746,6 +750,8 @@ 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */, + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */, ); path = Chat; sourceTree = ""; @@ -1516,6 +1522,7 @@ 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */, @@ -1554,6 +1561,7 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 987f7f3d41..abf6390196 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -203,7 +203,7 @@ public func chatResponse(_ s: String) -> ChatResponse { let jChats = jApiChats["chats"] as? NSArray { let chats = jChats.map { jChat in if let chatData = try? parseChatData(jChat) { - return chatData + return chatData.0 } return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") } @@ -213,8 +213,9 @@ public func chatResponse(_ s: String) -> ChatResponse { if let jApiChat = jResp["apiChat"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChat["user"] as Any), let jChat = jApiChat["chat"] as? NSDictionary, - let chat = try? parseChatData(jChat) { - return .apiChat(user: user, chat: chat) + let jNavInfo = jApiChat["navInfo"] as? NSDictionary, + let (chat, navInfo) = try? parseChatData(jChat, jNavInfo) { + return .apiChat(user: user, chat: chat, navInfo: navInfo) } } else if type == "chatCmdError" { if let jError = jResp["chatCmdError"] as? NSDictionary { @@ -247,10 +248,11 @@ private func errorJson(_ jDict: NSDictionary) -> String? { } } -func parseChatData(_ jChat: Any) throws -> ChatData { +func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) { let jChatDict = jChat as! NSDictionary let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!) let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!) + let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!) let jChatItems = jChatDict["chatItems"] as! NSArray let chatItems = jChatItems.map { jCI in if let ci: ChatItem = try? decodeObject(jCI) { @@ -262,7 +264,7 @@ func parseChatData(_ jChat: Any) throws -> ChatData { json: serializeJSON(jCI, options: .prettyPrinted) ?? "" ) } - return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats) + return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo) } func decodeObject(_ obj: Any) throws -> T { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 1df6d07813..cc8e7aa275 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -560,7 +560,7 @@ public enum ChatResponse: Decodable, Error { case chatStopped case chatSuspended case apiChats(user: UserRef, chats: [ChatData]) - case apiChat(user: UserRef, chat: ChatData) + case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) @@ -908,7 +908,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): return withUser(u, String(describing: chat)) + case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" @@ -1156,12 +1156,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)" } } } @@ -2003,6 +2007,11 @@ public struct ChatSettings: Codable, Hashable { public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) } +public struct NavigationInfo: Decodable { + public var afterUnread: Int = 0 + public var afterTotal: Int = 0 +} + public enum MsgFilter: String, Codable, Hashable { case none case all