diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 899a29b1d2..888152cf46 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,36 +43,12 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } -/// 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 - let indexRangeInParentItems: Range -} - class ItemsModel: ObservableObject { static let shared = ItemsModel() private let publisher = ObservableObjectPublisher() private var bag = Set() - var chatItemIds = Set() var reversedChatItems: [ChatItem] = [] { - willSet { - chatItemIds.removeAll() - chatItemIds.formUnion(newValue.map { $0.id }) - publisher.send() - } + willSet { publisher.send() } } var itemAdded = false { willSet { publisher.send() } diff --git a/apps/ios/Shared/Views/Chat/ChatItemGroups.swift b/apps/ios/Shared/Views/Chat/ChatItemGroups.swift new file mode 100644 index 0000000000..10e5ec4a1e --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemGroups.swift @@ -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 + let indexRangeInParentItems: Range +} +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 + var startIndexInParentItems: Int + + init(mergeCategory: CIMergeCategory?, items: [ListItem], revealed: Bool, showAvatar: Set, startIndexInParentItems: Int) { + self.mergeCategory = mergeCategory + self.items = items + self.revealed = revealed + self.showAvatar = showAvatar + self.startIndexInParentItems = startIndexInParentItems + } + + func reveal(_ reveal: Bool, revealedItems: inout Set) { + 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, itemAnchors: Array) -> 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.., 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, 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 + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 435c6c1eb8..99177acf67 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 @@ -47,6 +67,8 @@ struct ChatView: View { @State private var showDeleteSelectedMessages: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var initialChatItem: ChatItem? = nil + @State private var revealedItems: Set = [] + @State private var anchors: Array = [] @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -182,7 +204,7 @@ struct ChatView: View { dismiss() } } - .onChange(of: revealedChatItem) { _ in + .onChange(of: revealedItems.count) { _ in NotificationCenter.postReverseListNeedsLayout() } .onChange(of: im.isLoading) { isLoading in @@ -429,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, initialChatItem: $initialChatItem) { 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 @@ -446,11 +469,17 @@ 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: { pagination in loadChatItems(cInfo, pagination) } @@ -911,7 +940,7 @@ struct ChatView: View { if !duplicateFound { if let existingItem = im.reversedChatItems.first { - im.anchors = [existingItem.id] + anchors = [existingItem.id] } } @@ -922,7 +951,7 @@ struct ChatView: View { case .initial: await MainActor.run { im.reversedChatItems = chatItems.reversed() - im.anchors = [] + anchors = [] loadingItems = false } case let .after(chatItemId, _): @@ -932,10 +961,10 @@ struct ChatView: View { let wasSize = newItems.count let newItemIds = Set(chatItems.map { $0.id }) - let indexInAnchors = im.anchors.firstIndex { $0 == chatItemId } + let indexInAnchors = anchors.firstIndex { $0 == chatItemId } var anchorAfterChatItem: [Int64] = [] - if let indexInAnchors = indexInAnchors, indexInAnchors + 1 <= im.anchors.count { - anchorAfterChatItem = Array(im.anchors[indexInAnchors + 1..() var reachedBottom: Bool = false @@ -959,18 +988,18 @@ struct ChatView: View { await MainActor.run { im.reversedChatItems = newItems - var newAnchors = im.anchors.filter { !anchorsToRemove.contains($0) } + var newAnchors = anchors.filter { !anchorsToRemove.contains($0) } if reachedBottom { newAnchors = [] } else { - if let enlargedAnchorIndex = im.anchors.firstIndex(where: { $0 == chatItemId }) { + 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] } } - im.anchors = newAnchors + anchors = newAnchors loadingItems = false } case let .before(chatItemId, _): @@ -979,12 +1008,15 @@ struct ChatView: View { } 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.anchors = im.anchors.filter { !newItemIds.contains($0) } + 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(_, _): @@ -995,7 +1027,7 @@ struct ChatView: View { await MainActor.run { im.reversedChatItems = newItems if let lastItemId = chatItems.last?.id { - im.anchors.insert(lastItemId, at: 0) + anchors.insert(lastItemId, at: 0) } loadingItems = false } @@ -1022,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 @State private var deletingItem: ChatItem? = nil @State private var showDeleteMessage = false @@ -1037,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) @@ -1063,7 +1078,6 @@ 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 func markAsRead() { if markedRead { return @@ -1084,40 +1098,20 @@ struct ChatView: View { } } return 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) - } - } - } - } + VStack(spacing: 0) { + chatItemView(chatItem, range, prevItem, timeSeparation) + if let date = timeSeparation.date { + DateSeparator(date: date).padding(8) } - } 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) - } - } + } + .overlay { + if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(chatItem.id) + selectUnselectChatItem(select: !checked, chatItem) + } } } } @@ -1714,7 +1708,7 @@ struct ChatView: View { private func hideButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + onReveal(false) } } label: { Label( @@ -1789,7 +1783,7 @@ struct ChatView: View { private func revealButton(_ ci: ChatItem) -> Button { Button { withConditionalAnimation { - revealedChatItem = ci + onReveal(true) } } label: { Label( @@ -1802,7 +1796,7 @@ struct ChatView: View { private func expandButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = chatItem + onReveal(true) } } label: { Label( @@ -1815,7 +1809,7 @@ struct ChatView: View { private func shrinkButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + onReveal(false) } } label: { Label ( diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index 4abd7cfc3b..5df6742790 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -12,13 +12,13 @@ import SimpleXChat /// A List, which displays it's items in reverse order - from bottom to top struct ReverseList: UIViewControllerRepresentable { - let items: Array + 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: (_ pagination: ChatPagination) -> Void @@ -28,18 +28,18 @@ struct ReverseList: 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.scrollToItem(to: items.first(where: { $0.id == id }), position: .bottom) + controller.scroll(to: getIndexInParentItems(sections: groups.sections, itemId: id), position: .bottom) case .bottom: controller.scroll(to: 0, position: .top) } } else { - controller.update(items: items) + controller.update(groups: groups) } } @@ -47,10 +47,11 @@ struct ReverseList: UIViewControllerRepresentable { class Controller: UITableViewController { private enum Section { case main } var representer: ReverseList - private var dataSource: UITableViewDiffableDataSource! + private var dataSource: UITableViewDiffableDataSource! private var itemCount: Int = 0 private let updateFloatingButtons = PassthroughSubject() private var bag = Set() + private var revealedItems: Array = [] init(representer: ReverseList) { self.representer = representer @@ -76,11 +77,12 @@ struct ReverseList: UIViewControllerRepresentable { } // 3. Configure data source - self.dataSource = UITableViewDiffableDataSource( + self.dataSource = UITableViewDiffableDataSource( tableView: tableView ) { (tableView, indexPath, item) -> UITableViewCell? in if self.representer.scrollState == .atDestination, self.representer.initialChatItem == nil { - if indexPath.item > self.itemCount - preloadItem, let item = self.getItemAtPath(indexPath: IndexPath(row: self.itemCount - 1, section: 0)) { + 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)) @@ -157,7 +159,13 @@ struct ReverseList: UIViewControllerRepresentable { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - if let cItem = self.representer.initialChatItem, let indexPath = dataSource.indexPath(for: cItem) { + 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) @@ -183,14 +191,6 @@ struct ReverseList: UIViewControllerRepresentable { ) Task { representer.scrollState = .atDestination } } - - 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 { - self.scroll(to: nil, position: position) - } - } /// Scrolls to Item at index path /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` @@ -220,14 +220,22 @@ struct ReverseList: UIViewControllerRepresentable { } } - func update(items: [ChatItem]) { - var snapshot = NSDiffableDataSourceSnapshot() + func update(groups: SectionGroups) { + var snapshot = NSDiffableDataSourceSnapshot() + 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 - let countDiff = max(0, items.count - itemCount) - if tableView.contentOffset.y == 100, itemCount < items.count, itemCount > 0 { + let countDiff = max(0, revealedItems.count - itemCount) + if tableView.contentOffset.y == 100, itemCount < revealedItems.count, itemCount > 0 { dataSource.apply( snapshot, animatingDifferences: false @@ -254,7 +262,7 @@ struct ReverseList: UIViewControllerRepresentable { animated: false ) } - itemCount = items.count + itemCount = revealedItems.count updateFloatingButtons.send() } @@ -264,18 +272,18 @@ struct ReverseList: 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 } @@ -294,14 +302,6 @@ struct ReverseList: UIViewControllerRepresentable { } 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? { return nil } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index cd146d4292..5d99ac47ac 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; + B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemGroups.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; @@ -734,6 +736,7 @@ 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, + B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */, ); path = Chat; sourceTree = ""; @@ -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 */,