This commit is contained in:
Avently committed 2025-01-14 10:08:30 -08:00
1 parent bf54e22bee
commit 7a1cf8300d
5 files changed
+222 -82

No files matched your search

@@ -62,6 +62,10 @@ struct FramedItemView: View {
withAnimation {
scrollModel.scrollToItem(id: ci.id)
}
} else if let id = qi.itemId {
scrollModel.scrollToItem(id: id)
} else {
showQuotedItemDoesNotExistAlert()
}
}
} else if let itemForwarded = chatItem.meta.itemForwarded {
@@ -336,6 +340,13 @@ struct FramedItemView: View {
return videoWidth
}
}
private func showQuotedItemDoesNotExistAlert() {
AlertManager.shared.showAlertMsg(
title: "No message",
message: "This message was deleted or not received yet."
)
}
}
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
@@ -105,7 +105,7 @@ func apiLoadMessages(
}
case .around:
newItems.append(contentsOf: oldItems)
let newSplits = removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
let newSplits = await 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()
@@ -270,15 +270,15 @@ private func removeDuplicatesAndUpperSplits(
_ newItems: inout [ChatItem],
_ chat: Chat,
_ splits: [Int64],
_ visibleItemIndexesNonReversed: () -> ClosedRange<Int>
) -> [Int64] {
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int>
) async -> [Int64] {
if splits.isEmpty {
removeDuplicates(&newItems, chat)
return splits
}
var newSplits = splits
let visibleItemIndexes = visibleItemIndexesNonReversed()
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let (newIds, _) = mapItemsToIds(chat.chatItems)
var idsToTrim: [BoxedValue<Set<Int64>>] = []
idsToTrim.append(BoxedValue(Set()))
@@ -85,6 +85,22 @@ func preloadItems(_ mergedItems: MergedItems, _ allowLoadMoreItems: Bool, _ list
}
}
func oldestPartiallyVisibleListItemInListStateOrNull(_ mergedItems: MergedItems, _ listState: ListState) -> ListItem? {
if listState.lastVisibleItemIndex < mergedItems.items.count {
return mergedItems.items[listState.lastVisibleItemIndex].oldest()
} else {
return mergedItems.items.last?.oldest()
}
}
private func lastFullyVisibleIemInListState(_ mergedItems: MergedItems, _ listState: ListState) -> ChatItem? {
if listState.lastVisibleItemIndex < mergedItems.items.count {
return mergedItems.items[listState.lastVisibleItemIndex].newest().item
} else {
return mergedItems.items.last?.newest().item
}
}
private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? {
for split in splits {
// before any split
@@ -105,3 +121,16 @@ private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVis
return nil
}
func tryBlockAndSetLoadingMore(_ loadingMoreItems: Binding<Bool>, _ block: @escaping () async throws -> Void) async {
do {
await MainActor.run {
loadingMoreItems.wrappedValue = true
}
try await block()
} catch {
logger.error("Error loading more items: \(error)")
}
await MainActor.run {
loadingMoreItems.wrappedValue = false
}
}
+63 -22
View File
@@ -32,7 +32,7 @@ struct ChatView: View {
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var loadingItems = false
@State private var loadingMoreItems = false
@State private var searchMode = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@@ -78,7 +78,7 @@ struct ChatView: View {
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
chatItemsList()
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat, loadingMoreItems: $loadingMoreItems)
}
connectingText()
if selectedChatItems == nil {
@@ -177,6 +177,7 @@ struct ChatView: View {
showChatInfoSheet = false
selectedChatItems = nil
revealedItems = Set()
// LALAL
scrollModel.scrollToBottom()
stopAudioPlayer()
if let cId {
@@ -444,7 +445,7 @@ struct ChatView: View {
return GeometryReader { g in
let _ = logger.debug("LALAL RELOAD \(im.reversedChatItems.count)")
// LALAL CAN I CHANGE BINDING LIKE THIS IN ignoreLoadingRequests?
ReverseList(items: im.reversedChatItems, mergedItems: $mergedItems, revealedItems: $revealedItems, unreadCount: Binding.constant(chat.chatStats.unreadCount), scrollState: $scrollModel.state, allowLoadMoreItems: $allowLoadMoreItems, ignoreLoadingRequests: searchValueIsEmpty ? $ignoreLoadingRequests : Binding.constant([])) { index, mergedItem in
ReverseList(items: im.reversedChatItems, mergedItems: $mergedItems, revealedItems: $revealedItems, unreadCount: Binding.constant(chat.chatStats.unreadCount), scrollState: $scrollModel.state, loadingMoreItems: $loadingMoreItems, allowLoadMoreItems: $allowLoadMoreItems, ignoreLoadingRequests: searchValueIsEmpty ? $ignoreLoadingRequests : Binding.constant([])) { index, mergedItem in
let ci = switch mergedItem {
case let .single(item, _): item.item
case let .grouped(items, _, _, _, _, _, _): items.boxedValue.last!.item
@@ -477,12 +478,16 @@ struct ChatView: View {
)
.environmentObject(theme) // crashes without this line when scrolling to the first unread in ReverseList
.id(ci.id) // Required to trigger `onAppear` on iOS15
} loadItems: { pagination, visibleItemIndexesNonReversed in
await loadChatItems(cInfo, pagination, visibleItemIndexesNonReversed)
} loadItems: { unchecked, pagination, visibleItemIndexesNonReversed in
if unchecked {
await loadChatItemsUnchecked(cInfo, pagination, visibleItemIndexesNonReversed)
} else {
await loadChatItems(cInfo, pagination, visibleItemIndexesNonReversed)
}
}
.onAppear {
mergedItems = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState)
loadLastItems($loadingItems, chat.chatInfo)
loadLastItems($loadingMoreItems, chat.chatInfo)
allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true
@@ -498,7 +503,7 @@ struct ChatView: View {
mergedItems = MergedItems.create(im.reversedChatItems, unreadCount, revealedItems, ItemsModel.shared.chatState)
}
.onChange(of: chat.id) { _ in
loadLastItems($loadingItems, chat.chatInfo)
loadLastItems($loadingMoreItems, chat.chatInfo)
allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true
@@ -541,22 +546,29 @@ struct ChatView: View {
@Published var isNearBottom: Bool = true
@Published var date: Date?
@Published var isDateVisible: Bool = false
var mergedItems: MergedItems = MergedItems.init(items: [], splits: [], indexInParentItems: [:])
var totalUnread: Int = 0
var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem?
func updateOnListChange(_ listState: ListState) {
func updateOnListChange(_ mergedItems: MergedItems, _ listState: ListState) {
let im = ItemsModel.shared
let unreadBelow =
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
{
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else {
0
}
// let unreadBelow =
// if let id = listState.bottomItemId,
// let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
// {
// im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
// if chatItem.isRcvNew { unread += 1 }
// }
// } else {
// 0
// }
let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(mergedItems, listState)
let unreadBelow = if let lastVisibleItem {
max(0, totalUnread - lastVisibleItem.unreadBefore)
} else {
-1
}
let date: Date? =
if let topItemDate = listState.topItemDate {
Calendar.current.startOfDay(for: topItemDate)
@@ -571,6 +583,7 @@ struct ChatView: View {
it.unreadBelow = unreadBelow
it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
it.mergedItems = mergedItems
}
// set floating button indication mode
@@ -617,6 +630,7 @@ struct ChatView: View {
let theme: AppTheme
let scrollModel: ReverseListScrollModel
let chat: Chat
@Binding var loadingMoreItems: Bool
@ObservedObject var model = FloatingButtonModel.shared
var body: some View {
@@ -638,7 +652,12 @@ struct ChatView: View {
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToNextPage()
if let index = model.mergedItems.items.lastIndex(where: { $0.hasUnread() }) {
// scroll to the top unread item
loadingMoreItems = true
scrollModel.scrollToRow(row: index)
loadingMoreItems = false
}
}
.contextMenu {
Button {
@@ -904,8 +923,8 @@ struct ChatView: View {
}
private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination, _ visibleItemIndexesNonReversed: @escaping () -> ClosedRange<Int> = { 0 ... 0 }) async -> Bool {
if loadingItems { return false }
loadingItems = true
if loadingMoreItems { return false }
loadingMoreItems = true
var chatItemsAvailable = true
var itemsCountChanged = false
// Load additional items until the page is +50 large after merging
@@ -923,12 +942,34 @@ struct ChatView: View {
chatItemsAvailable = itemsCountChanged
//}
logger.debug("LALAL ITEMCOUNTCHANGED \(itemsCountChanged) \(oldCount) \(im.reversedChatItems.count)")
await MainActor.run {
loadingItems = false
loadingMoreItems = false
}
return true
}
private func loadChatItemsUnchecked(_ cInfo: ChatInfo, _ pagination: ChatPagination, _ visibleItemIndexesNonReversed: @escaping () -> ClosedRange<Int> = { 0 ... 0 }) async -> Bool {
var chatItemsAvailable = true
var itemsCountChanged = false
// Load additional items until the page is +50 large after merging
//while chatItemsAvailable && filtered(im.reversedChatItems).count < loadItemsPerPage {
let oldCount = im.reversedChatItems.count
await apiLoadMessages(
cInfo.chatType,
cInfo.apiId,
pagination,
im.chatState,
searchText,
visibleItemIndexesNonReversed
)
itemsCountChanged = im.reversedChatItems.count != oldCount
chatItemsAvailable = itemsCountChanged
//}
logger.debug("LALAL ITEMCOUNTCHANGED \(itemsCountChanged) \(oldCount) \(im.reversedChatItems.count)")
return true
}
func stopAudioPlayer() {
VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.chatView = [:]
+115 -56
View File
@@ -18,6 +18,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
@Binding var unreadCount: Int
@Binding var scrollState: ReverseListScrollModel.State
@Binding var loadingMoreItems: Bool
@Binding var allowLoadMoreItems: Bool
@Binding var ignoreLoadingRequests: [Int64]
@@ -25,8 +26,8 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
/// Index, merged item
let content: (Int, MergedItem) -> Content
// pagination, visibleItemIndexesNonReversed
let loadItems: (ChatPagination, @escaping @MainActor () -> ClosedRange<Int>) async -> Bool
// unchecked, pagination, visibleItemIndexesNonReversed
let loadItems: (Bool, ChatPagination, @escaping @MainActor () -> ClosedRange<Int>) async -> Bool
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
@@ -40,7 +41,14 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
let row = items.firstIndex(where: { $0.id == id })
if let row {
controller.scroll(to: row, position: .bottom)
} else {
controller.scrollToItem(id)
}
case let .row(row):
controller.scroll(to: row, position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
}
@@ -111,30 +119,35 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
//if indexPath.item > self.itemCount - 8 {
logger.debug("LALAL ITEM \(indexPath.item)")
Task {
// do not put it outside Task because it will be stackoverflow error
let state = await MainActor.run {
self.getListState()
logger.debug("LALAL ITEM \(indexPath.item)")
Task {
// do not put it outside Task because it will be stackoverflow error
let state = await MainActor.run {
self.getListState()
}
if let state, prevFirstVisible != state.firstVisibleItemIndex {
logger.debug("LALAL LOADING before")
if representer.scrollState != .atDestination {
// do not try to load anything in order to not interupt scrolling
return
}
if let state, prevFirstVisible != state.firstVisibleItemIndex {
prevFirstVisible = state.firstVisibleItemIndex
logger.debug("LALAL LOADING before")
await preloadItems(self.representer.mergedItems, self.representer.allowLoadMoreItems, state, self.representer.$ignoreLoadingRequests) { pagination in
logger.debug("LALAL LOADING INSIDE")
let triedToLoad = await self.representer.loadItems(pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) })
if triedToLoad {
let t = self.tableView.panGestureRecognizer.translation(in: self.tableView.superview!)
if t.y != 0 {
self.translationToApply = t
}
// stop scrolling
self.tableView.setContentOffset(self.tableView.contentOffset, animated: false)
prevFirstVisible = state.firstVisibleItemIndex
await preloadItems(self.representer.mergedItems, self.representer.allowLoadMoreItems, state, self.representer.$ignoreLoadingRequests) { pagination in
logger.debug("LALAL LOADING INSIDE")
let triedToLoad = await self.representer.loadItems(false, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) })
let superview = self.tableView.superview
if triedToLoad, let superview {
let t = self.tableView.panGestureRecognizer.translation(in: superview)
if t.y != 0 {
self.translationToApply = t
}
return triedToLoad
// stop scrolling
self.tableView.setContentOffset(self.tableView.contentOffset, animated: false)
}
return triedToLoad
}
}
}
//}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
@@ -167,7 +180,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
ChatView.FloatingButtonModel.shared.updateOnListChange(representer.mergedItems, listState)
}
}
.store(in: &bag)
@@ -328,10 +341,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
} else if wasCount != snapshot.numberOfItems {
logger.debug("LALAL drag \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
// tableView.panGestureRecognizer.isEnabled = false
// logger.debug("LALAL drag2 \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
// tableView.panGestureRecognizer.isEnabled = false
// logger.debug("LALAL drag2 \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
if tableView.isDecelerating {
// CATransaction.begin()
// CATransaction.begin()
itemsInPrevSnapshot = itemsInCurrentSnapshot
tableView.beginUpdates()
dataSource.apply(
@@ -339,7 +352,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animatingDifferences: false
)
tableView.endUpdates()
// CATransaction.commit()
// CATransaction.commit()
tableView.panGestureRecognizer.isEnabled = true
} else {
itemsInPrevSnapshot = itemsInCurrentSnapshot
@@ -348,30 +361,30 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animatingDifferences: false
)
//if snapshot.itemIdentifiers[0] == prevSnapshot.itemIdentifiers[0] {
// added new items to top
// added new items to top
//} else {
// added new items to bottom
// logger.debug("LALAL WAS HEIGHT \(wasContentHeight) now \(self.tableView.contentSize.height), offset was \(wasOffset), now \(self.tableView.contentOffset.y), will be \(self.tableView.contentOffset.y + (self.tableView.contentSize.height - wasContentHeight)), countDiff \(countDiff), wasVisibleRow \(wasFirstVisibleRow), wasFirstVisibleOffset \(wasFirstVisibleOffset)")
self.tableView.scrollToRow(
at: IndexPath(row: max(0, min(snapshot.numberOfItems - 1, countDiff + wasFirstVisibleRow)), section: 0),
at: .top,
animated: false
)
self.tableView.setContentOffset(
CGPoint(x: 0, y: self.tableView.contentOffset.y - wasFirstVisibleOffset),
animated: false
)
let state = getListState()!
logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)")
// added new items to bottom
// logger.debug("LALAL WAS HEIGHT \(wasContentHeight) now \(self.tableView.contentSize.height), offset was \(wasOffset), now \(self.tableView.contentOffset.y), will be \(self.tableView.contentOffset.y + (self.tableView.contentSize.height - wasContentHeight)), countDiff \(countDiff), wasVisibleRow \(wasFirstVisibleRow), wasFirstVisibleOffset \(wasFirstVisibleOffset)")
self.tableView.scrollToRow(
at: IndexPath(row: max(0, min(snapshot.numberOfItems - 1, countDiff + wasFirstVisibleRow)), section: 0),
at: .top,
animated: false
)
self.tableView.setContentOffset(
CGPoint(x: 0, y: self.tableView.contentOffset.y - wasFirstVisibleOffset),
animated: false
)
let state = getListState()!
logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)")
if let t = translationToApply {
// self.tableView.panGestureRecognizer.setTranslation(CGPointMake(t.x, t.y), in: self.tableView.superview!)
// self.tableView.panGestureRecognizer.setTranslation(CGPointMake(t.x, t.y), in: self.tableView.superview!)
let o = self.tableView.contentOffset
// UIView.animate(withDuration: 0.5) {
// self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: false)
// self.tableView.layoutIfNeeded()
// }
// UIView.animate(withDuration: 0.5) {
// self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: false)
// self.tableView.layoutIfNeeded()
// }
self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: true)
translationToApply = nil
}
@@ -392,22 +405,23 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
visibleRows.last?.row ?? 0 < representer.mergedItems.items.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs
} else {
nil
}
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs
} else {
nil
}
let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) })
let firstVisibleOffset: CGFloat? = if let row = firstVisible?.row {
offsetForRow(row)
} else { nil }
let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) })
logger.debug("LALAL LAST \(lastVisible?.item ?? -2)")
let bottomItemId: ChatItem.ID? =
if let firstVisible {
representer.mergedItems.items[firstVisible.row].newest().item.id
} else {
nil
}
if let firstVisible {
representer.mergedItems.items[firstVisible.row].newest().item.id
} else {
nil
}
return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.row ?? 0, lastVisibleItemIndex: lastVisible?.row ?? 0, firstVisibleItemOffset: firstVisibleOffset ?? 0)
}
return nil
@@ -435,6 +449,46 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
}
func scrollToItem(_ itemId: Int64) {
Task {
logger.debug("LALAL SCROLL TO ITEM \(itemId) \(ItemsModel.shared.reversedChatItems.count)")
do {
var index = representer.mergedItems.indexInParentItems[itemId]
// setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to
// it will trigger loading more items and will scroll to incorrect position (because of trimming)
await MainActor.run {
representer.loadingMoreItems = true
}
logger.debug("LALAL SCROLL TO ITEM \(index ?? -2)")
if index == nil {
let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
let oldSize = ItemsModel.shared.reversedChatItems.count
_ = await self.representer.loadItems(true, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems))})
var repeatsLeft = 50
while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
try await Task.sleep(nanoseconds: 20_000000)
repeatsLeft -= 1
logger.debug("LALAL SCROLL REPEATS \(repeatsLeft)")
}
index = representer.mergedItems.indexInParentItems[itemId]
}
logger.debug("LALAL SCROLL TO ITEM2 \(index ?? -3) \(ItemsModel.shared.reversedChatItems.count)")
if let index {
scroll(to: min(ItemsModel.shared.reversedChatItems.count - 1, index), position: .bottom)
} else {
await MainActor.run {
representer.scrollState = .atDestination
}
}
} catch {
logger.error("Error scrolling to item: \(error)")
}
await MainActor.run {
representer.loadingMoreItems = false
}
}
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
@@ -502,6 +556,7 @@ class ReverseListScrollModel: ObservableObject {
enum Destination: Equatable {
case nextPage
case item(ChatItem.ID)
case row(Int)
case bottom
}
@@ -522,6 +577,10 @@ class ReverseListScrollModel: ObservableObject {
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
func scrollToRow(row: Int) {
state = .scrollingTo(.row(row))
}
}
fileprivate let cellReuseId = "hostingCell"