mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3298caef28 | |||
| ec4bf4c1d9 | |||
| ec95b14234 | |||
| 7722803586 | |||
| 96800ab0b4 | |||
| a360da4828 | |||
| bafaf0f0ee | |||
| 0a9917de03 | |||
| 7d9e0dd041 | |||
| 37ba244e04 | |||
| d21dc77566 | |||
| bd5a51c366 | |||
| 8c31bd5dd1 | |||
| 5a78f11c8b | |||
| 3044231eb6 | |||
| 192500341e | |||
| 57a01fdce8 | |||
| 061dccbb98 | |||
| 4fe2b80705 | |||
| 1e419b8790 | |||
| 8467d5ff17 | |||
| 04f9d53964 | |||
| ee1d9fff36 | |||
| 19a34b630c | |||
| 7adfe631bf | |||
| a7844b3f7b | |||
| 9ee1d8d0b9 | |||
| 095cc286de | |||
| 1cb3c25478 | |||
| c6b1413b78 | |||
| d4e460ac8c |
@@ -322,13 +322,13 @@ let loadItemsPerPage = 50
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
if case let .apiChat(_, chat, _) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
|
||||
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, _) = r { return chat.chatItems }
|
||||
throw r
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ struct FramedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||
@EnvironmentObject var sectionModel: ReverseListSectionModel
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var preview: UIImage?
|
||||
@@ -46,12 +47,43 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
if let qi = chatItem.quotedItem {
|
||||
let notFoundItemTitle = NSLocalizedString("Message no longer available", comment: "alert title")
|
||||
let notFoundItemMessage = NSLocalizedString("The quoted message you are trying to view has been deleted.", comment: "alert message")
|
||||
|
||||
ciQuoteView(qi)
|
||||
.onTapGesture {
|
||||
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: ci.id)
|
||||
if let itemId = qi.itemId {
|
||||
Task {
|
||||
if let reversedPage = await loadItemsAround(chat.chatInfo, itemId) {
|
||||
await MainActor.run {
|
||||
let im = ItemsModel.shared
|
||||
let reversedPageToAppend = self.sectionModel.handleSectionInsertion(
|
||||
candidateSection: .destination,
|
||||
reversedPage: reversedPage,
|
||||
allItems: im.reversedChatItems
|
||||
)
|
||||
im.reversedChatItems.append(contentsOf: reversedPageToAppend)
|
||||
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: itemId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text(notFoundItemTitle),
|
||||
message: Text(notFoundItemMessage)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text(notFoundItemTitle),
|
||||
message: Text(notFoundItemMessage)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if let itemForwarded = chatItem.meta.itemForwarded {
|
||||
|
||||
@@ -23,6 +23,7 @@ struct ChatView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State @ObservedObject var chat: Chat
|
||||
@StateObject private var scrollModel = ReverseListScrollModel()
|
||||
@StateObject private var sectionModel = ReverseListSectionModel()
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@@ -31,7 +32,6 @@ struct ChatView: View {
|
||||
@State private var customUserProfile: Profile?
|
||||
@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 = ""
|
||||
@@ -168,7 +168,6 @@ struct ChatView: View {
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
showChatInfoSheet = false
|
||||
selectedChatItems = nil
|
||||
scrollModel.scrollToBottom()
|
||||
stopAudioPlayer()
|
||||
if let cId {
|
||||
if let c = chatModel.getChat(cId) {
|
||||
@@ -187,10 +186,11 @@ struct ChatView: View {
|
||||
if !isLoading,
|
||||
im.reversedChatItems.count <= loadItemsPerPage,
|
||||
filtered(im.reversedChatItems).count < 10 {
|
||||
loadChatItems(chat.chatInfo)
|
||||
loadChatItems(chat.chatInfo, .toOldest, .bottom, nil)
|
||||
}
|
||||
}
|
||||
.environmentObject(scrollModel)
|
||||
.environmentObject(sectionModel)
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
stopAudioPlayer()
|
||||
@@ -363,6 +363,34 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
||||
sectionModel.resetSections(items: im.reversedChatItems)
|
||||
|
||||
let minUnreadItemId = chat.chatStats.minUnreadItemId
|
||||
if minUnreadItemId > 0 {
|
||||
if im.reversedChatItems.contains(where: { $0.id == minUnreadItemId }) {
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: minUnreadItemId, position: .middle)
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
if let reversedPage = await loadItemsAround(chat.chatInfo, minUnreadItemId) {
|
||||
await MainActor.run {
|
||||
let reversedPageToAppend = self.sectionModel.handleSectionInsertion(
|
||||
candidateSection: .current,
|
||||
reversedPage: reversedPage,
|
||||
allItems: im.reversedChatItems
|
||||
)
|
||||
im.reversedChatItems.append(contentsOf: reversedPageToAppend)
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: minUnreadItemId, position: .middle, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
@@ -400,25 +428,11 @@ struct ChatView: View {
|
||||
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
|
||||
}
|
||||
|
||||
private func filtered(_ reversedChatItems: Array<ChatItem>) -> Array<ChatItem> {
|
||||
reversedChatItems
|
||||
.enumerated()
|
||||
.filter { (index, chatItem) in
|
||||
if let mergeCategory = chatItem.mergeCategory, index > 0 {
|
||||
mergeCategory != reversedChatItems[index - 1].mergeCategory
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.map { $0.element }
|
||||
}
|
||||
|
||||
|
||||
private func chatItemsList() -> some 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, scrollState: $scrollModel.state, sectionModel: sectionModel) { ci in
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
? voiceNoFrame
|
||||
@@ -438,18 +452,22 @@ struct ChatView: View {
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
)
|
||||
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
||||
} loadPage: {
|
||||
loadChatItems(cInfo)
|
||||
} loadPage: { (direction, section, chatItemId) in
|
||||
loadChatItems(cInfo, direction, section, chatItemId)
|
||||
}
|
||||
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
||||
.padding(.vertical, -InvertedTableView.inset)
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { _ in
|
||||
Task { await loadChat(chat: chat, search: searchText) }
|
||||
Task {
|
||||
await loadChat(chat: chat, search: searchText)
|
||||
sectionModel.resetSections(items: im.reversedChatItems)
|
||||
}
|
||||
}
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
im.itemAdded = false
|
||||
sectionModel.resetSections(items: im.reversedChatItems)
|
||||
if FloatingButtonModel.shared.isReallyNearBottom {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
@@ -838,10 +856,18 @@ struct ChatView: View {
|
||||
await MainActor.run { forwardedChatItems = fci }
|
||||
}
|
||||
}
|
||||
|
||||
private func boundaryReached(_ direction: ChatScrollDirection) -> Bool {
|
||||
return (direction == .toLatest && sectionModel.boundaries.latest) || (direction == .toOldest && sectionModel.boundaries.oldest)
|
||||
}
|
||||
|
||||
private func setBoundary(_ direction: ChatScrollDirection) {
|
||||
direction == .toLatest ? (sectionModel.boundaries.latest = true) : (sectionModel.boundaries.oldest = true)
|
||||
}
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo) {
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ direction: ChatScrollDirection, _ section: ChatSection, _ chatItem: ChatItem?) {
|
||||
Task {
|
||||
if loadingItems || firstPage { return }
|
||||
if loadingItems || boundaryReached(direction) { return }
|
||||
loadingItems = true
|
||||
do {
|
||||
var reversedPage = Array<ChatItem>()
|
||||
@@ -849,11 +875,14 @@ struct ChatView: View {
|
||||
// Load additional items until the page is +50 large after merging
|
||||
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
|
||||
let pagination: ChatPagination =
|
||||
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
|
||||
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
||||
} else {
|
||||
.last(count: loadItemsPerPage)
|
||||
}
|
||||
if direction == .toOldest, let lastItem = chatItem ?? reversedPage.last ?? im.reversedChatItems.last {
|
||||
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
||||
} else if direction == .toLatest, let firstItem = chatItem ?? reversedPage.first ?? im.reversedChatItems.first {
|
||||
.after(chatItemId: firstItem.id, count: loadItemsPerPage)
|
||||
} else {
|
||||
.last(count: loadItemsPerPage)
|
||||
}
|
||||
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
@@ -863,12 +892,29 @@ struct ChatView: View {
|
||||
chatItemsAvailable = !chatItems.isEmpty
|
||||
reversedPage.append(contentsOf: chatItems.reversed())
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if reversedPage.count == 0 {
|
||||
firstPage = true
|
||||
if reversedPage.count == 0 {
|
||||
setBoundary(direction)
|
||||
} else {
|
||||
im.reversedChatItems.append(contentsOf: reversedPage)
|
||||
let reversedPageToAppend = self.sectionModel.handleSectionInsertion(
|
||||
candidateSection: section,
|
||||
reversedPage: reversedPage,
|
||||
allItems: im.reversedChatItems
|
||||
)
|
||||
|
||||
if direction == .toLatest {
|
||||
let at = if let chatItemId = chatItem?.id, let index = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) {
|
||||
index
|
||||
} else {
|
||||
0
|
||||
}
|
||||
im.reversedChatItems.insert(contentsOf: reversedPageToAppend, at: at)
|
||||
} else {
|
||||
im.reversedChatItems.append(contentsOf: reversedPageToAppend)
|
||||
}
|
||||
}
|
||||
self.sectionModel.manageActiveSection(scrollDirection: direction)
|
||||
loadingItems = false
|
||||
}
|
||||
} catch let error {
|
||||
@@ -1886,6 +1932,36 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
||||
}
|
||||
}
|
||||
}
|
||||
private func filtered(_ reversedChatItems: Array<ChatItem>) -> Array<ChatItem> {
|
||||
reversedChatItems
|
||||
.enumerated()
|
||||
.filter { (index, chatItem) in
|
||||
if let mergeCategory = chatItem.mergeCategory, index > 0 {
|
||||
mergeCategory != reversedChatItems[index - 1].mergeCategory
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.map { $0.element }
|
||||
}
|
||||
|
||||
func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) async -> [ChatItem]? {
|
||||
do {
|
||||
var reversedPage = Array<ChatItem>()
|
||||
let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2)
|
||||
var chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: ""
|
||||
)
|
||||
|
||||
return chatItems.reversed()
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -10,17 +10,29 @@ import SwiftUI
|
||||
import Combine
|
||||
import SimpleXChat
|
||||
|
||||
enum ChatScrollDirection {
|
||||
case toLatest
|
||||
case toOldest
|
||||
case none
|
||||
}
|
||||
|
||||
enum ChatSection: CaseIterable {
|
||||
case current
|
||||
case destination
|
||||
case bottom
|
||||
}
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<ChatItem>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
|
||||
@ObservedObject var sectionModel: ReverseListSectionModel
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (ChatItem) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
|
||||
let loadPage: (ChatScrollDirection, ChatSection, ChatItem?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(representer: self)
|
||||
}
|
||||
@@ -32,10 +44,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
case let .item(id, position, animated):
|
||||
controller.scrollToItem(id: id, position: position, animated: animated)
|
||||
case .bottom:
|
||||
controller.scroll(to: 0, position: .top)
|
||||
controller.scroll(to: 0, position: .top, section: .bottom, shouldTryAnimate: true)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
@@ -44,13 +56,15 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var dataSource: UITableViewDiffableDataSource<ChatSection, ChatItem>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
private var lastContentOffset: CGFloat = 0
|
||||
private var scrollDirection: ChatScrollDirection = .none
|
||||
private var requestedRange = false
|
||||
|
||||
init(representer: ReverseList) {
|
||||
self.representer = representer
|
||||
super.init(style: .plain)
|
||||
@@ -73,13 +87,24 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
self.dataSource = UITableViewDiffableDataSource<ChatSection, ChatItem>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
self.representer.loadPage()
|
||||
if let section = self.dataSource.sectionIdentifier(for: indexPath.section), self.representer.scrollState == .atDestination, !self.requestedRange {
|
||||
if self.representer.sectionModel.activeSection == section {
|
||||
let itemCount = self.getTotalItemsInItemSection(indexPath: indexPath)
|
||||
if self.scrollDirection == .toOldest, indexPath.item > itemCount - 8 {
|
||||
let lastItem = self.getLastItemInItemSection(indexPath: indexPath)
|
||||
self.requestedRange = !self.representer.sectionModel.boundaries.oldest
|
||||
self.representer.loadPage(.toOldest, section, lastItem)
|
||||
} else if self.scrollDirection == .toLatest, indexPath.item < 8 {
|
||||
self.requestedRange = !self.representer.sectionModel.boundaries.latest
|
||||
let firstItem = self.getFirstItemInItemSection(indexPath: indexPath)
|
||||
self.representer.loadPage(.toLatest, section, firstItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -161,17 +186,35 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
/// Scrolls to a given item
|
||||
func scrollToItem(id: Int64, position: UITableView.ScrollPosition, animated: Bool) {
|
||||
if let loadedIndex = self.representer.items.firstIndex(where: { $0.id == id }) {
|
||||
let ci = representer.items[loadedIndex]
|
||||
if let indexPath = dataSource.indexPath(for: ci),
|
||||
let section = dataSource.sectionIdentifier(for: indexPath.section) {
|
||||
self.scroll(to: indexPath.row, position: position, section: section, shouldTryAnimate: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls to Item at index path
|
||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition, section: ChatSection, shouldTryAnimate: Bool) {
|
||||
let activeSectionBeforeScroll = representer.sectionModel.activeSection
|
||||
|
||||
if section != activeSectionBeforeScroll {
|
||||
Task {
|
||||
representer.sectionModel.changeActiveSection(section)
|
||||
}
|
||||
}
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
animated = shouldTryAnimate
|
||||
}
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
if let index, let sectionIndex = dataSource.index(for: section), tableView.numberOfRows(inSection: sectionIndex) != 0 {
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
at: IndexPath(row: index, section: sectionIndex),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
@@ -181,13 +224,25 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
Task {
|
||||
representer.scrollState = .atDestination
|
||||
if activeSectionBeforeScroll != section {
|
||||
representer.sectionModel.maybeDropSection(activeSectionBeforeScroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
requestedRange = false
|
||||
var snapshot = NSDiffableDataSourceSnapshot<ChatSection, ChatItem>()
|
||||
let sections = self.representer.sectionModel.getSectionsOrdered()
|
||||
let itemsBySection = self.itemsBySection(items: items)
|
||||
snapshot.appendSections(sections)
|
||||
sections.forEach { sec in
|
||||
if let sectionItems = itemsBySection[sec] {
|
||||
snapshot.appendItems(sectionItems, toSection: sec)
|
||||
}
|
||||
}
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
@@ -205,6 +260,15 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let currentOffset = scrollView.contentOffset.y
|
||||
if currentOffset > lastContentOffset {
|
||||
scrollDirection = .toOldest
|
||||
} else if currentOffset < lastContentOffset {
|
||||
scrollDirection = .toLatest
|
||||
} else {
|
||||
scrollDirection = .none
|
||||
}
|
||||
lastContentOffset = currentOffset
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
@@ -212,15 +276,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.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
|
||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }),
|
||||
let cI = self.dataSource.itemIdentifier(for: lastVisible) {
|
||||
cI.meta.itemTs
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }),
|
||||
let cI = self.dataSource.itemIdentifier(for: firstVisible) {
|
||||
cI.id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
@@ -238,6 +305,40 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
}
|
||||
|
||||
private func getTotalItemsInItemSection(indexPath: IndexPath) -> Int {
|
||||
return self.tableView.numberOfRows(inSection: indexPath.section)
|
||||
}
|
||||
|
||||
private func getLastItemInItemSection(indexPath: IndexPath) -> ChatItem? {
|
||||
let numberOfRows = self.getTotalItemsInItemSection(indexPath: indexPath)
|
||||
|
||||
return if numberOfRows > 0,
|
||||
let lastItem = self.dataSource.itemIdentifier(for: IndexPath(row: numberOfRows - 1, section: indexPath.section)) {
|
||||
lastItem
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func getFirstItemInItemSection(indexPath: IndexPath) -> ChatItem? {
|
||||
let firstIndexPath = IndexPath(item: 0, section: indexPath.section)
|
||||
return if let firstItem = self.dataSource.itemIdentifier(for: firstIndexPath) {
|
||||
firstItem
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func itemsBySection(items: [ChatItem]) -> [ChatSection: [ChatItem]] {
|
||||
let itemsBySection = items.reduce(into: [ChatSection: [ChatItem]]()) { result, ci in
|
||||
if let sec = self.representer.sectionModel.getItemSection(ci.id) {
|
||||
result[sec, default: []].append(ci)
|
||||
}
|
||||
}
|
||||
|
||||
return itemsBySection
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
@@ -292,7 +393,7 @@ class ReverseListScrollModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case nextPage
|
||||
case item(ChatItem.ID)
|
||||
case item(ChatItem.ID, UITableView.ScrollPosition, Bool)
|
||||
case bottom
|
||||
}
|
||||
|
||||
@@ -310,8 +411,177 @@ class ReverseListScrollModel: ObservableObject {
|
||||
state = .scrollingTo(.bottom)
|
||||
}
|
||||
|
||||
func scrollToItem(id: ChatItem.ID) {
|
||||
state = .scrollingTo(.item(id))
|
||||
func scrollToItem(id: ChatItem.ID, position: UITableView.ScrollPosition? = .bottom, animated: Bool = true) {
|
||||
state = .scrollingTo(.item(id, position ?? .bottom, animated))
|
||||
}
|
||||
}
|
||||
|
||||
struct SectionBoundaryReached {
|
||||
var oldest: Bool
|
||||
var latest: Bool
|
||||
}
|
||||
|
||||
/// Manages ``ReverseList`` sections
|
||||
class ReverseListSectionModel: ObservableObject {
|
||||
static let shared = ReverseListSectionModel()
|
||||
static let MAX_SECTION_SIZE = 500
|
||||
static let MIN_SECTION_SIZE = 200
|
||||
@Published private(set) var activeSection: ChatSection = .bottom
|
||||
@Published var boundaries = SectionBoundaryReached(oldest: false, latest: false)
|
||||
@Published private var itemSection: [Int64: ChatSection] = [:]
|
||||
@Published private var sections = Set<ChatSection>()
|
||||
|
||||
func getItemSection(_ id: Int64) -> ChatSection? {
|
||||
return itemSection[id]
|
||||
}
|
||||
|
||||
func changeActiveSection(_ section: ChatSection) {
|
||||
activeSection = section
|
||||
boundaries.latest = false
|
||||
boundaries.oldest = false
|
||||
}
|
||||
|
||||
func maybeDropSection(_ section: ChatSection) {
|
||||
if section == .bottom { return }
|
||||
let im = ItemsModel.shared
|
||||
|
||||
sections.remove(section)
|
||||
im.reversedChatItems = im.reversedChatItems.filter { it in itemSection[it.id] != section }
|
||||
}
|
||||
|
||||
private func activeSectionItemsCount() -> Int {
|
||||
// TODO: check
|
||||
return itemSection.values.filter { $0 == self.activeSection }.count
|
||||
}
|
||||
|
||||
private func splitSection(originalSection: ChatSection, newSection: ChatSection) {
|
||||
var originalSectionCount = 0
|
||||
let im = ItemsModel.shared
|
||||
|
||||
im.reversedChatItems.forEach { it in
|
||||
if itemSection[it.id] == originalSection {
|
||||
if originalSectionCount < ReverseListSectionModel.MIN_SECTION_SIZE {
|
||||
originalSectionCount += 1
|
||||
} else {
|
||||
itemSection[it.id] = newSection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func trimSection(scrollDirection: ChatScrollDirection, section: ChatSection) {
|
||||
let im = ItemsModel.shared
|
||||
var i = scrollDirection == .toLatest ? 0 : im.reversedChatItems.count - 1
|
||||
let moveToNextIndex: () -> Bool = {
|
||||
if scrollDirection == .toLatest {
|
||||
if i < im.reversedChatItems.count - 1 {
|
||||
i += 1
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if i > 0 {
|
||||
i -= 1
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
var sectionCount = 0
|
||||
var completed = false
|
||||
var toRemoveIndexes = IndexSet()
|
||||
|
||||
while (!completed) {
|
||||
let it = im.reversedChatItems[i]
|
||||
if itemSection[it.id] == section {
|
||||
if sectionCount >= ReverseListSectionModel.MAX_SECTION_SIZE {
|
||||
toRemoveIndexes.insert(i)
|
||||
itemSection.removeValue(forKey: it.id)
|
||||
} else {
|
||||
sectionCount += 1
|
||||
}
|
||||
}
|
||||
completed = moveToNextIndex()
|
||||
}
|
||||
|
||||
if (!toRemoveIndexes.isEmpty) {
|
||||
im.reversedChatItems.remove(atOffsets: toRemoveIndexes)
|
||||
}
|
||||
}
|
||||
|
||||
func manageActiveSection(scrollDirection: ChatScrollDirection) {
|
||||
let activeSectionCount = activeSectionItemsCount()
|
||||
|
||||
switch self.activeSection {
|
||||
case .bottom:
|
||||
if activeSectionCount > ReverseListSectionModel.MAX_SECTION_SIZE {
|
||||
if scrollDirection == .toLatest {
|
||||
boundaries.oldest = false
|
||||
self.trimSection(scrollDirection: scrollDirection, section: self.activeSection)
|
||||
} else {
|
||||
splitSection(originalSection: .bottom, newSection: .current)
|
||||
changeActiveSection(.current)
|
||||
sections.insert(.current)
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if activeSectionCount > ReverseListSectionModel.MAX_SECTION_SIZE {
|
||||
if scrollDirection == .toLatest {
|
||||
boundaries.oldest = false
|
||||
} else {
|
||||
boundaries.latest = false
|
||||
}
|
||||
self.trimSection(scrollDirection: scrollDirection, section: self.activeSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSectionsOrdered() -> [ChatSection] {
|
||||
var orderedSections: [ChatSection] = [.bottom]
|
||||
|
||||
if (sections.contains(.current)) {
|
||||
orderedSections.append(.current)
|
||||
}
|
||||
|
||||
if (sections.contains(.destination)) {
|
||||
orderedSections.append(.destination)
|
||||
}
|
||||
|
||||
return orderedSections
|
||||
}
|
||||
|
||||
func resetSections(items: [ChatItem]) {
|
||||
itemSection = Dictionary(uniqueKeysWithValues: items.map { ($0.id, .bottom) })
|
||||
}
|
||||
|
||||
func handleSectionInsertion(candidateSection: ChatSection, reversedPage: [ChatItem], allItems: [ChatItem]) -> [ChatItem] {
|
||||
var reversedPageToAppend = Array<ChatItem>()
|
||||
var targetSection = candidateSection
|
||||
|
||||
if let sectionIntersectionItem = reversedPage.first(where: { itemSection[$0.id] != nil && itemSection[$0.id] != candidateSection }) {
|
||||
let sectionToDrop = itemSection[sectionIntersectionItem.id] == .bottom ? candidateSection : (itemSection[sectionIntersectionItem.id] ?? .destination)
|
||||
targetSection = itemSection[sectionIntersectionItem.id] == .bottom ? .bottom : candidateSection
|
||||
itemSection = itemSection.mapValues { section in
|
||||
section == sectionToDrop ? targetSection : section
|
||||
}
|
||||
if (sectionToDrop != targetSection) {
|
||||
sections.remove(sectionToDrop)
|
||||
}
|
||||
}
|
||||
|
||||
reversedPage.forEach { ci in
|
||||
if itemSection[ci.id] == nil {
|
||||
reversedPageToAppend.append(ci)
|
||||
itemSection[ci.id] = targetSection
|
||||
}
|
||||
}
|
||||
sections.insert(targetSection)
|
||||
self.activeSection = targetSection
|
||||
|
||||
return reversedPageToAppend
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,8 +213,10 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
||||
if let jApiChat = jResp["apiChat"] as? NSDictionary,
|
||||
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
|
||||
let jChat = jApiChat["chat"] as? NSDictionary,
|
||||
let chat = try? parseChatData(jChat) {
|
||||
return .apiChat(user: user, chat: chat)
|
||||
let jSection = jApiChat["section"] as? String,
|
||||
let chat = try? parseChatData(jChat),
|
||||
let section = ChatLandingSection(rawValue: jSection) {
|
||||
return .apiChat(user: user, chat: chat, section: section)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
|
||||
@@ -546,7 +546,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatStopped
|
||||
case chatSuspended
|
||||
case apiChats(user: UserRef, chats: [ChatData])
|
||||
case apiChat(user: UserRef, chat: ChatData)
|
||||
case apiChat(user: UserRef, chat: ChatData, section: ChatLandingSection)
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case userProtoServers(user: UserRef, servers: UserProtoServers)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
@@ -888,7 +888,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatStopped: return noDetails
|
||||
case .chatSuspended: return noDetails
|
||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
|
||||
case let .apiChat(u, chat, section): return withUser(u, "section: \(String(describing: section))\n\(String(describing: chat))")
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
@@ -1133,12 +1133,16 @@ public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
case around(chatItemId: Int64, count: Int)
|
||||
case initial(count: Int)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .last(count): return "count=\(count)"
|
||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
|
||||
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
|
||||
case let .initial(count): return "initial=\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,6 +1619,11 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
)
|
||||
}
|
||||
|
||||
public enum ChatLandingSection: String, Decodable, Hashable {
|
||||
case latest = "latest"
|
||||
case unread = "unread"
|
||||
}
|
||||
|
||||
public enum ContactStatus: String, Decodable, Hashable {
|
||||
case active = "active"
|
||||
case deleted = "deleted"
|
||||
|
||||
Reference in New Issue
Block a user