diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e6970d4728..d7b05d8723 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,6 +43,11 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +struct ChatGap { + let index: Int + let size: Int +} + class ItemsModel: ObservableObject { static let shared = ItemsModel() private let publisher = ObservableObjectPublisher() @@ -53,6 +58,9 @@ 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 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f0e64b2dce..a442089ca9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -319,6 +319,7 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { } let loadItemsPerPage = 50 +let preloadItem = 8 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)) @@ -350,9 +351,14 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { if clearItems { await MainActor.run { im.reversedChatItems = [] } } - 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() + if let gap = gap { + im.gap = ChatGap(index: loadItemsPerPage, size: gap) + } else { + im.gap = nil + } m.updateChatInfo(chat.chatInfo) } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 35ae1f9715..0af81a9be0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -348,7 +348,7 @@ struct FramedItemView: View { do { var reversedPage = Array() let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2) - let (chatItems, _) = try await apiGetChatItems( + let (chatItems, gap) = try await apiGetChatItems( type: cInfo.chatType, id: cInfo.apiId, pagination: pagination, @@ -359,6 +359,9 @@ struct FramedItemView: View { await MainActor.run { ItemsModel.shared.reversedChatItems.append(contentsOf: reversedPage) + if let size = gap { + ItemsModel.shared.gap = ChatGap(index: reversedPage.count, size: size) + } } return reversedPage diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 06ee173237..d233353ad9 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -423,7 +423,7 @@ struct ChatView: View { let cInfo = chat.chatInfo let mergedItems = filtered(im.reversedChatItems) return GeometryReader { g in - ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in + ReverseList(items: mergedItems, gap: im.gap, scrollState: $scrollModel.state) { ci in let voiceNoFrame = voiceWithoutFrame(ci) let maxWidth = cInfo.chatType == .group ? voiceNoFrame diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index eac881ebd4..cea8b4b47a 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -13,6 +13,7 @@ import SimpleXChat /// A List, which displays it's items in reverse order - from bottom to top struct ReverseList: UIViewControllerRepresentable { let items: Array + let gap: ChatGap? @Binding var scrollState: ReverseListScrollModel.State @@ -33,15 +34,15 @@ struct ReverseList: UIViewControllerRepresentable { case .nextPage: controller.scrollToNextPage() case let .item(id): - controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) + controller.scrollToItem(to: items.first(where: { $0.id == id }), position: .bottom) case .bottom: controller.scroll(to: 0, position: .top) case let .unread(id): - controller.scrollToUnread(to: items.firstIndex(where: { $0.id == id })) + controller.scrollToUnread(to: items.first(where: { $0.id == id })) } } else { - logger.error("[scrolling] not scrolling") - controller.update(items: items) + logger.error("[scrolling] not scrolling gap: (\(gap?.index ?? -1), \(gap?.size ?? 0)") + controller.update(items: items, gap: gap) } } @@ -81,9 +82,15 @@ struct ReverseList: UIViewControllerRepresentable { self.dataSource = UITableViewDiffableDataSource( 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 { + if indexPath.item > self.itemCount - preloadItem, let item = self.getItemAtPath(indexPath: IndexPath(row: self.itemCount - 1, section: 0)) { + logger.error("[scrolling] requesting page \(item.text)") + self.representer.loadPage() + } else if let item = self.getFirstItemBeforePlacholder(indexPath) { + logger.error("[scrolling] needs items in top \(item.text)") + } else if let item = self.getFirstItemAfterPlacholder(indexPath) { + logger.error("[scrolling] needs items in bottom \(item.text)") + } } let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) if #available(iOS 16.0, *) { @@ -166,15 +173,23 @@ struct ReverseList: UIViewControllerRepresentable { Task { representer.scrollState = .atDestination } } - func scrollToUnread(to index: Int?) { - if let index = index { - if isVisible(indexPath: IndexPath(row: index, section: 0)) { + func scrollToUnread(to cItem: ChatItem?) { + if let it = cItem, let indexPath = dataSource.indexPath(for: it) { + if isVisible(indexPath: indexPath) { self.scroll(to: 0, position: .top) } else { - self.scroll(to: index, position: .bottom) + self.scroll(to: indexPath.row, position: .bottom) } } else { - self.scroll(to: index, position: .bottom) + self.scroll(to: nil, position: .bottom) + } + } + + func scrollToItem(to cItem: ChatItem?, position: UITableView.ScrollPosition) { + if let it = cItem, let indexPath = dataSource.indexPath(for: it) { + self.scroll(to: indexPath.row, position: position) + } else { + scroll(to: nil, position: position) } } @@ -200,14 +215,23 @@ struct ReverseList: UIViewControllerRepresentable { Task { representer.scrollState = .atDestination } } - func update(items: [ChatItem]) { + func update(items: [ChatItem], gap: ChatGap?) { var snapshot = NSDiffableDataSourceSnapshot() + let cItems: [ChatItem] + if let gap = gap { + var itemsCopy = items + let blanks = (0..: UIViewControllerRepresentable { animated: false ) } - itemCount = items.count + itemCount = cItems.count updateFloatingButtons.send() } @@ -254,6 +278,68 @@ struct ReverseList: UIViewControllerRepresentable { relativeFrame.minY < tableView.frame.height - InvertedTableView.inset } else { false } } + + private func getItemAtPath(indexPath: IndexPath) -> ChatItem? { + return if let firstItem = self.dataSource.itemIdentifier(for: indexPath) { + firstItem + } else { + nil + } + } + + private func getFirstItemAfterPlacholder(_ indexPath: IndexPath) -> ChatItem? { + if indexPath.row < preloadItem { + return nil + } + + let closesPlaceholderRow = (0.. ChatItem? { + if indexPath.row + preloadItem >= itemCount { + return nil + } + + let closesPlaceholderRow = (0..= 0 { + if let item = getItemAtPath(indexPath: IndexPath(row: rowIndex, section: 0)), !item.isPlaceholder { + return item + } + rowIndex -= 1 + } + + return nil + } else { + return nil + } + } } /// `UIHostingConfiguration` back-port for iOS14 and iOS15 diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1bd5673f01..cde3286b05 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2339,6 +2339,7 @@ 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 @@ -2664,6 +2665,30 @@ public struct ChatItem: Identifiable, Decodable, Hashable { return item } + public static func placeholder(_ idx: Int) -> ChatItem { + var item = ChatItem( + chatDir: CIDirection.directSnd, + meta: CIMeta( + itemId: Int64(idx * -10), + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: true, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .text("")), + quotedItem: nil, + file: nil + ) + item.isPlaceholder = true + return item + } + public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem { ChatItem( chatDir: chatDir ?? .directSnd,