This commit is contained in:
Diogo
2024-11-10 00:55:42 +00:00
parent 1b2d7e93f7
commit bc97b262e5
8 changed files with 155 additions and 150 deletions
+20 -6
View File
@@ -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<Int>
let indexRangeInParentItems: Range<Int>
}
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 {
+13 -7
View File
@@ -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 {
@@ -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<ChatItem>()
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<Int64>()
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<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
+111 -60
View File
@@ -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[..<sliceSize])
}
}
private func getFirstUnreadItem() -> 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<ChatItem>()
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..<loadItemsPerPage + dedupedreversePage.count)
}
im.reversedChatItems.append(contentsOf: dedupedreversePage)
case let .after(chatItemId, _):
let index = im.reversedChatItems.firstIndex { $0.id == chatItemId }
if let index {
im.reversedChatItems.insert(contentsOf: dedupedreversePage, at: index)
}
case .around(_, _): break
}
let wasSize = newItems.count
let newItemIds = Set(chatItems.map { $0.id })
let indexInGaps = im.gaps.firstIndex { $0 == chatItemId }
var gapsAfterChatItem: [Int64] = []
if let indexInGaps = indexInGaps, indexInGaps + 1 <= im.gaps.count {
gapsAfterChatItem = Array(im.gaps[indexInGaps + 1..<im.gaps.count])
}
var gapsToRemove = Set<Int64>()
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<Int>?, 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
@@ -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)
+2 -3
View File
@@ -222,13 +222,12 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
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
@@ -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 {
+1 -26
View File
@@ -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(