mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
jumps unreads and gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -348,7 +348,7 @@ struct FramedItemView: View {
|
||||
do {
|
||||
var reversedPage = Array<ChatItem>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import SimpleXChat
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<ChatItem>
|
||||
let gap: ChatGap?
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
|
||||
@@ -33,15 +34,15 @@ struct ReverseList<Content: View>: 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<Content: View>: UIViewControllerRepresentable {
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
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<Content: View>: 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<Content: View>: UIViewControllerRepresentable {
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
func update(items: [ChatItem], gap: ChatGap?) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
let cItems: [ChatItem]
|
||||
if let gap = gap {
|
||||
var itemsCopy = items
|
||||
let blanks = (0..<gap.size).map { index in ChatItem.placeholder(index + 1) }
|
||||
itemsCopy.insert(contentsOf: blanks, at: gap.index)
|
||||
cItems = itemsCopy
|
||||
} else {
|
||||
cItems = items
|
||||
}
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
snapshot.appendItems(cItems)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
animatingDifferences: itemCount != 0 && abs(cItems.count - itemCount) == 1
|
||||
)
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
@@ -216,7 +240,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
itemCount = cItems.count
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
@@ -254,6 +278,68 @@ struct ReverseList<Content: View>: 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..<preloadItem).first { index in
|
||||
if let item = getItemAtPath(indexPath: IndexPath(row: indexPath.row - index, section: 0)) {
|
||||
return item.isPlaceholder
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if let closesPlaceholderRow {
|
||||
var rowIndex = indexPath.row - closesPlaceholderRow
|
||||
while rowIndex < self.tableView.numberOfRows(inSection: 0) {
|
||||
if let item = getItemAtPath(indexPath: IndexPath(row: rowIndex, section: 0)), !item.isPlaceholder {
|
||||
return item
|
||||
}
|
||||
rowIndex += 1
|
||||
}
|
||||
|
||||
return nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func getFirstItemBeforePlacholder(_ indexPath: IndexPath) -> ChatItem? {
|
||||
if indexPath.row + preloadItem >= itemCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
let closesPlaceholderRow = (0..<preloadItem).first { index in
|
||||
if let item = getItemAtPath(indexPath: IndexPath(row: indexPath.row + index, section: 0)) {
|
||||
return item.isPlaceholder
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if let closesPlaceholderRow {
|
||||
var rowIndex = indexPath.row + closesPlaceholderRow
|
||||
while rowIndex >= 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user