diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c8a4a42f21..603fe302f7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,9 +43,23 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +/// Represents a gap 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 gap. +/// 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 gap. The first index in this range is the position of the gap itself. +/// For instance, if the array `[0, 1, 2, -100-, 101]` has a gap at index 3, `indexRange` +/// would be `3..<5`, indicating the gap 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 gap appears in the visible list. +/// The first index in this range points to where the gap starts in the UI. struct ChatGap { - let index: Int - let size: Int + let itemId: Int64 + let indexRange: Range + let indexRangeInParentItems: Range } class ItemsModel: ObservableObject { @@ -63,14 +77,12 @@ class ItemsModel: ObservableObject { var itemAdded = false { willSet { publisher.send() } } - var gap: ChatGap? = nil { - willSet { publisher.send() } - } // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false @Published var showLoadingProgress = false + @State var gaps: [ChatItem.ID] = [] init() { publisher @@ -99,7 +111,9 @@ class ItemsModel: ObservableObject { if let chat = ChatModel.shared.getChat(chatId) { await MainActor.run { self.isLoading = true } // try? await Task.sleep(nanoseconds: 5000_000000) - await loadChat(chat: chat) + if let gap = await loadChat(chat: chat) { + self.gaps = [gap] + } navigationTimeout.cancel() progressTimeout.cancel() await MainActor.run { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 54104f1b28..75bafbe539 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -322,15 +322,15 @@ let loadItemsPerPage = 100 let preloadItem = 25 let idealChatListSize = 300 -func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { +func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> (Chat, Int?) { let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search)) - if case let .apiChat(_, chat, _) = r { return Chat.init(chat) } + if case let .apiChat(_, chat, gap) = r { return (Chat.init(chat), gap) } throw r } -func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { +func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> ([ChatItem], Int?) { 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, gap) = r { return (chat.chatItems, gap) } if case .chatCmdError(_, _) = r { if case .chatError(_, let chatError) = r { if case .errorStore(let storeError) = chatError { @@ -343,7 +343,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { +func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async -> ChatItem.ID? { do { let cInfo = chat.chatInfo let m = ChatModel.shared @@ -352,17 +352,23 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { if clearItems { await MainActor.run { im.reversedChatItems = [] - im.gap = nil } } - let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) + let (chat, gap) = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) await MainActor.run { im.reversedChatItems = chat.chatItems.reversed() m.updateChatInfo(chat.chatInfo) } + + return if gap != nil { + chat.chatItems[loadItemsPerPage].id + } else { + nil + } } catch let error { logger.error("loadChat error: \(responseError(error))") } + return nil } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 389668a48f..3302782f3c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -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,47 +342,7 @@ struct FramedItemView: View { } else { return false } - } - - private func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) async -> [ChatItem]? { - do { - let im = ItemsModel.shared - var reversedPage = Array() - let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2) - let chatItems = try await apiGetChatItems( - type: cInfo.chatType, - id: cInfo.apiId, - pagination: pagination, - search: "" - ) - - let dedupedChatItems = chatItems.filter { !im.chatItemIds.contains($0.id) } - reversedPage.append(contentsOf: dedupedChatItems.reversed()) - - var itemsToDrop = Set() - await MainActor.run { - if let g = im.gap { - itemsToDrop = Set(im.reversedChatItems.suffix(g.size).map { $0.id }) - } - - let itemCount = im.reversedChatItems.count - im.reversedChatItems.append(contentsOf: reversedPage) - } - - if (itemsToDrop.count > 0) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - Task { - im.reversedChatItems.removeAll(where: { itemsToDrop.contains($0.id) }) - } - } - } - - return reversedPage - } catch let error { - logger.error("apiGetChat error: \(responseError(error))") - return nil - } - } + } } @ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 8f994a9db9..ee8e53ca2f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -77,7 +77,7 @@ struct ChatView: View { VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { chatItemsList() - FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat, onScrollToBottom: onScrollToBottom) + FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) } connectingText() if selectedChatItems == nil { @@ -481,14 +481,6 @@ struct ChatView: View { } } - private func onScrollToBottom() { - scrollModel.scrollToBottom() - if let g = im.gap { - let sliceSize = min(g.index, idealChatListSize) - im.reversedChatItems = Array(im.reversedChatItems[.. ChatItem? { var maybeItem: ChatItem? = nil for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) { @@ -535,9 +527,6 @@ struct ChatView: View { } else { 0 } - if unreadBelow > 0, let g = im.gap, g.size > 0, g.index < bottomItemIndex { - unreadBelow += g.size - } let date: Date? = if let topItemDate = listState.topItemDate { Calendar.current.startOfDay(for: topItemDate) @@ -599,7 +588,6 @@ struct ChatView: View { let theme: AppTheme let scrollModel: ReverseListScrollModel let chat: Chat - let onScrollToBottom: () -> Void @ObservedObject var model = FloatingButtonModel.shared var body: some View { @@ -640,14 +628,14 @@ struct ChatView: View { .foregroundColor(theme.colors.primary) } .onTapGesture { - onScrollToBottom() + scrollModel.scrollToBottom() } } else if !model.isNearBottom { circleButton { Image(systemName: "chevron.down") .foregroundColor(theme.colors.primary) } - .onTapGesture { onScrollToBottom() } + .onTapGesture { scrollModel.scrollToBottom() } } } .padding() @@ -888,56 +876,119 @@ struct ChatView: View { private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination = .initial(count: loadItemsPerPage)) { Task { if loadingItems { return } - if case .before = pagination, firstPage { return } loadingItems = true + do { - var reversedPage = Array() - var chatItemsAvailable = true - // Load additional items until the page is +50 large after merging - while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { - let chatPagination: ChatPagination = switch pagination { - case let .before(chatItemId, count): .before(chatItemId: reversedPage.last?.id ?? chatItemId, count: count) - case let .last(count): .last(count: count) - case let .initial(count): .initial(count: count) - case let .after(chatItemId, count): - .after(chatItemId: reversedPage.first?.id ?? chatItemId, count: count) - case .around(_, _): throw RuntimeError("Unsupported pagination type for loading chat items: \(pagination)") + let (chatItems, gap) = 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 { + im.reversedChatItems = chatItems.reversed() + im.gaps = [] + loadingItems = false + } + case let .initial(count): + await MainActor.run { + im.reversedChatItems = chatItems.reversed() + + if gap != nil { + let firstBottomItem = chatItems[count] + im.gaps = [firstBottomItem.id] + } else { + im.gaps = [] + } + loadingItems = false + } + case let .after(chatItemId, _): + guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { + return } - let chatItems = try await apiGetChatItems( - type: cInfo.chatType, - id: cInfo.apiId, - pagination: chatPagination, - search: searchText - ) - chatItemsAvailable = !chatItems.isEmpty - reversedPage.append(contentsOf: chatItems.reversed()) - } - let dedupedreversePage = reversedPage.filter { !im.chatItemIds.contains($0.id) } - - await MainActor.run { - if reversedPage.count == 0 { - firstPage = true - } else if dedupedreversePage.count > 0 { - switch pagination { - case .last, .initial: - im.reversedChatItems.append(contentsOf: dedupedreversePage) - case .before(_, _): - if im.reversedChatItems.count + dedupedreversePage.count > idealChatListSize, - dedupedreversePage.count <= loadItemsPerPage { - im.reversedChatItems.removeSubrange(loadItemsPerPage..() + var reachedBottom: Bool = false + + newItems.removeAll { item in + let isDuplicate = newItemIds.contains(item.id) + if indexInGaps != nil && newItemIds.contains(item.id) { + if gapsAfterChatItem.contains(item.id) { + gapsAfterChatItem.removeAll { $0 == item.id } + gapsToRemove.insert(item.id) + } else if reachedBottom == false && gapsAfterChatItem.isEmpty { + // We passed all gaps and found a duplicated item below all of them, indicating no more gaps 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 newGaps = im.gaps.filter { !gapsToRemove.contains($0) } + + if reachedBottom { + newGaps = [] + } else { + if let enlargedGapIndex = im.gaps.firstIndex(where: { $0 == chatItemId }) { + // Move the gap to the end of the loaded items. + newGaps[enlargedGapIndex] = chatItems.last?.id ?? newGaps[enlargedGapIndex] + } + } + + im.gaps = newGaps + 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 { + im.reversedChatItems = newItems + im.gaps = im.gaps.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 { + im.gaps.insert(lastItemId, at: 0) + } + loadingItems = false } - loadingItems = false } + } catch let error { logger.error("apiGetChat error: \(responseError(error))") await MainActor.run { loadingItems = false } @@ -1336,7 +1387,7 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed, !ci.isPlaceholder { + if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { reactionsGroup diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index fd72b5b515..67a045ab9a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index 49212d6d80..659c2a99c5 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -222,13 +222,12 @@ struct ReverseList: UIViewControllerRepresentable { func update(items: [ChatItem]) { var snapshot = NSDiffableDataSourceSnapshot() - let originalSize = tableView.numberOfRows(inSection: 0) snapshot.appendSections([.main]) snapshot.appendItems(items) dataSource.defaultRowAnimation = .none - let countDiff = max(0, items.count - originalSize) - if tableView.contentOffset.y == 100, originalSize < items.count, originalSize > 0 { + let countDiff = max(0, items.count - itemCount) + if tableView.contentOffset.y == 100, itemCount < items.count, itemCount > 0 { dataSource.apply( snapshot, animatingDifferences: false diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 940dae8c29..7b185d8211 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -119,7 +119,7 @@ struct SelectedItemsBottomToolbar: View { dee = dee && ci.meta.deletable && !ci.localNote onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil - fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isPlaceholder + fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0ce258ad77..8c99c20c8f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2339,7 +2339,6 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var viewTimestamp = Date.now public var isLiveDummy: Bool = false - public var isPlaceholder: Bool = false private enum CodingKeys: String, CodingKey { case chatDir, meta, content, formattedText, quotedItem, reactions, file @@ -2468,7 +2467,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } public var allowAddReaction: Bool { - meta.itemDeleted == nil && !isLiveDummy && !isPlaceholder && reactions.filter({ $0.userReacted }).count < 3 + meta.itemDeleted == nil && !isLiveDummy && reactions.filter({ $0.userReacted }).count < 3 } public func autoReceiveFile() -> CIFile? { @@ -2664,30 +2663,6 @@ public struct ChatItem: Identifiable, Decodable, Hashable { item.isLiveDummy = true return item } - - public static func placeholder(idx: Int, text: String, chatDir: CIDirection) -> ChatItem { - var item = ChatItem( - chatDir: chatDir, - meta: CIMeta( - itemId: Int64(idx * -10), - itemTs: .now, - itemText: text, - itemStatus: .rcvRead, - createdAt: .now, - updatedAt: .now, - itemDeleted: nil, - itemEdited: false, - itemLive: false, - deletable: false, - editable: false - ), - content: .sndMsgContent(msgContent: .text(text)), - quotedItem: nil, - file: nil - ) - item.isPlaceholder = true - return item - } public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem { ChatItem(