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 {
|
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))
|
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
|
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] {
|
||||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
|
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
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct FramedItemView: View {
|
|||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||||
|
@EnvironmentObject var sectionModel: ReverseListSectionModel
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var preview: UIImage?
|
var preview: UIImage?
|
||||||
@@ -46,12 +47,43 @@ struct FramedItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let qi = chatItem.quotedItem {
|
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)
|
ciQuoteView(qi)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
if let itemId = qi.itemId {
|
||||||
withAnimation {
|
Task {
|
||||||
scrollModel.scrollToItem(id: ci.id)
|
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 {
|
} else if let itemForwarded = chatItem.meta.itemForwarded {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct ChatView: View {
|
|||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@State @ObservedObject var chat: Chat
|
@State @ObservedObject var chat: Chat
|
||||||
@StateObject private var scrollModel = ReverseListScrollModel()
|
@StateObject private var scrollModel = ReverseListScrollModel()
|
||||||
|
@StateObject private var sectionModel = ReverseListSectionModel()
|
||||||
@State private var showChatInfoSheet: Bool = false
|
@State private var showChatInfoSheet: Bool = false
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var showAddMembersSheet: Bool = false
|
||||||
@State private var composeState = ComposeState()
|
@State private var composeState = ComposeState()
|
||||||
@@ -31,7 +32,6 @@ struct ChatView: View {
|
|||||||
@State private var customUserProfile: Profile?
|
@State private var customUserProfile: Profile?
|
||||||
@State private var connectionCode: String?
|
@State private var connectionCode: String?
|
||||||
@State private var loadingItems = false
|
@State private var loadingItems = false
|
||||||
@State private var firstPage = false
|
|
||||||
@State private var revealedChatItem: ChatItem?
|
@State private var revealedChatItem: ChatItem?
|
||||||
@State private var searchMode = false
|
@State private var searchMode = false
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@@ -168,7 +168,6 @@ struct ChatView: View {
|
|||||||
.onChange(of: chatModel.chatId) { cId in
|
.onChange(of: chatModel.chatId) { cId in
|
||||||
showChatInfoSheet = false
|
showChatInfoSheet = false
|
||||||
selectedChatItems = nil
|
selectedChatItems = nil
|
||||||
scrollModel.scrollToBottom()
|
|
||||||
stopAudioPlayer()
|
stopAudioPlayer()
|
||||||
if let cId {
|
if let cId {
|
||||||
if let c = chatModel.getChat(cId) {
|
if let c = chatModel.getChat(cId) {
|
||||||
@@ -187,10 +186,11 @@ struct ChatView: View {
|
|||||||
if !isLoading,
|
if !isLoading,
|
||||||
im.reversedChatItems.count <= loadItemsPerPage,
|
im.reversedChatItems.count <= loadItemsPerPage,
|
||||||
filtered(im.reversedChatItems).count < 10 {
|
filtered(im.reversedChatItems).count < 10 {
|
||||||
loadChatItems(chat.chatInfo)
|
loadChatItems(chat.chatInfo, .toOldest, .bottom, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environmentObject(scrollModel)
|
.environmentObject(scrollModel)
|
||||||
|
.environmentObject(sectionModel)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
VideoPlayerView.players.removeAll()
|
VideoPlayerView.players.removeAll()
|
||||||
stopAudioPlayer()
|
stopAudioPlayer()
|
||||||
@@ -363,6 +363,34 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
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 {
|
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
|
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 {
|
private func chatItemsList() -> some View {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
let mergedItems = filtered(im.reversedChatItems)
|
let mergedItems = filtered(im.reversedChatItems)
|
||||||
return GeometryReader { g in
|
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 voiceNoFrame = voiceWithoutFrame(ci)
|
||||||
let maxWidth = cInfo.chatType == .group
|
let maxWidth = cInfo.chatType == .group
|
||||||
? voiceNoFrame
|
? voiceNoFrame
|
||||||
@@ -438,18 +452,22 @@ struct ChatView: View {
|
|||||||
forwardedChatItems: $forwardedChatItems
|
forwardedChatItems: $forwardedChatItems
|
||||||
)
|
)
|
||||||
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
||||||
} loadPage: {
|
} loadPage: { (direction, section, chatItemId) in
|
||||||
loadChatItems(cInfo)
|
loadChatItems(cInfo, direction, section, chatItemId)
|
||||||
}
|
}
|
||||||
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
||||||
.padding(.vertical, -InvertedTableView.inset)
|
.padding(.vertical, -InvertedTableView.inset)
|
||||||
.onTapGesture { hideKeyboard() }
|
.onTapGesture { hideKeyboard() }
|
||||||
.onChange(of: searchText) { _ in
|
.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
|
.onChange(of: im.itemAdded) { added in
|
||||||
if added {
|
if added {
|
||||||
im.itemAdded = false
|
im.itemAdded = false
|
||||||
|
sectionModel.resetSections(items: im.reversedChatItems)
|
||||||
if FloatingButtonModel.shared.isReallyNearBottom {
|
if FloatingButtonModel.shared.isReallyNearBottom {
|
||||||
scrollModel.scrollToBottom()
|
scrollModel.scrollToBottom()
|
||||||
}
|
}
|
||||||
@@ -839,9 +857,17 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadChatItems(_ cInfo: ChatInfo) {
|
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, _ direction: ChatScrollDirection, _ section: ChatSection, _ chatItem: ChatItem?) {
|
||||||
Task {
|
Task {
|
||||||
if loadingItems || firstPage { return }
|
if loadingItems || boundaryReached(direction) { return }
|
||||||
loadingItems = true
|
loadingItems = true
|
||||||
do {
|
do {
|
||||||
var reversedPage = Array<ChatItem>()
|
var reversedPage = Array<ChatItem>()
|
||||||
@@ -849,11 +875,14 @@ struct ChatView: View {
|
|||||||
// Load additional items until the page is +50 large after merging
|
// Load additional items until the page is +50 large after merging
|
||||||
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
|
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
|
||||||
let pagination: ChatPagination =
|
let pagination: ChatPagination =
|
||||||
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
|
if direction == .toOldest, let lastItem = chatItem ?? reversedPage.last ?? im.reversedChatItems.last {
|
||||||
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
||||||
} else {
|
} else if direction == .toLatest, let firstItem = chatItem ?? reversedPage.first ?? im.reversedChatItems.first {
|
||||||
.last(count: loadItemsPerPage)
|
.after(chatItemId: firstItem.id, count: loadItemsPerPage)
|
||||||
}
|
} else {
|
||||||
|
.last(count: loadItemsPerPage)
|
||||||
|
}
|
||||||
|
|
||||||
let chatItems = try await apiGetChatItems(
|
let chatItems = try await apiGetChatItems(
|
||||||
type: cInfo.chatType,
|
type: cInfo.chatType,
|
||||||
id: cInfo.apiId,
|
id: cInfo.apiId,
|
||||||
@@ -863,12 +892,29 @@ struct ChatView: View {
|
|||||||
chatItemsAvailable = !chatItems.isEmpty
|
chatItemsAvailable = !chatItems.isEmpty
|
||||||
reversedPage.append(contentsOf: chatItems.reversed())
|
reversedPage.append(contentsOf: chatItems.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if reversedPage.count == 0 {
|
if reversedPage.count == 0 {
|
||||||
firstPage = true
|
setBoundary(direction)
|
||||||
} else {
|
} 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
|
loadingItems = false
|
||||||
}
|
}
|
||||||
} catch let error {
|
} 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 {
|
struct ChatView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
|
|||||||
@@ -10,16 +10,28 @@ import SwiftUI
|
|||||||
import Combine
|
import Combine
|
||||||
import SimpleXChat
|
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
|
/// A List, which displays it's items in reverse order - from bottom to top
|
||||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||||
let items: Array<ChatItem>
|
let items: Array<ChatItem>
|
||||||
|
|
||||||
@Binding var scrollState: ReverseListScrollModel.State
|
@Binding var scrollState: ReverseListScrollModel.State
|
||||||
|
@ObservedObject var sectionModel: ReverseListSectionModel
|
||||||
/// Closure, that returns user interface for a given item
|
/// Closure, that returns user interface for a given item
|
||||||
let content: (ChatItem) -> Content
|
let content: (ChatItem) -> Content
|
||||||
|
|
||||||
let loadPage: () -> Void
|
let loadPage: (ChatScrollDirection, ChatSection, ChatItem?) -> Void
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> Controller {
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
Controller(representer: self)
|
Controller(representer: self)
|
||||||
@@ -32,10 +44,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
switch destination {
|
switch destination {
|
||||||
case .nextPage:
|
case .nextPage:
|
||||||
controller.scrollToNextPage()
|
controller.scrollToNextPage()
|
||||||
case let .item(id):
|
case let .item(id, position, animated):
|
||||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
controller.scrollToItem(id: id, position: position, animated: animated)
|
||||||
case .bottom:
|
case .bottom:
|
||||||
controller.scroll(to: 0, position: .top)
|
controller.scroll(to: 0, position: .top, section: .bottom, shouldTryAnimate: true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
controller.update(items: items)
|
controller.update(items: items)
|
||||||
@@ -44,12 +56,14 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
/// Controller, which hosts SwiftUI cells
|
/// Controller, which hosts SwiftUI cells
|
||||||
class Controller: UITableViewController {
|
class Controller: UITableViewController {
|
||||||
private enum Section { case main }
|
|
||||||
var representer: ReverseList
|
var representer: ReverseList
|
||||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
private var dataSource: UITableViewDiffableDataSource<ChatSection, ChatItem>!
|
||||||
private var itemCount: Int = 0
|
private var itemCount: Int = 0
|
||||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||||
private var bag = Set<AnyCancellable>()
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var lastContentOffset: CGFloat = 0
|
||||||
|
private var scrollDirection: ChatScrollDirection = .none
|
||||||
|
private var requestedRange = false
|
||||||
|
|
||||||
init(representer: ReverseList) {
|
init(representer: ReverseList) {
|
||||||
self.representer = representer
|
self.representer = representer
|
||||||
@@ -75,11 +89,22 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Configure data source
|
// 3. Configure data source
|
||||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
self.dataSource = UITableViewDiffableDataSource<ChatSection, ChatItem>(
|
||||||
tableView: tableView
|
tableView: tableView
|
||||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
if indexPath.item > self.itemCount - 8 {
|
if let section = self.dataSource.sectionIdentifier(for: indexPath.section), self.representer.scrollState == .atDestination, !self.requestedRange {
|
||||||
self.representer.loadPage()
|
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)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
@@ -162,16 +187,34 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
Task { representer.scrollState = .atDestination }
|
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
|
/// Scrolls to Item at index path
|
||||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
/// - 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
|
var animated = false
|
||||||
if #available(iOS 16.0, *) {
|
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(
|
tableView.scrollToRow(
|
||||||
at: IndexPath(row: index, section: 0),
|
at: IndexPath(row: index, section: sectionIndex),
|
||||||
at: position,
|
at: position,
|
||||||
animated: animated
|
animated: animated
|
||||||
)
|
)
|
||||||
@@ -181,13 +224,25 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
animated: animated
|
animated: animated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Task { representer.scrollState = .atDestination }
|
Task {
|
||||||
|
representer.scrollState = .atDestination
|
||||||
|
if activeSectionBeforeScroll != section {
|
||||||
|
representer.sectionModel.maybeDropSection(activeSectionBeforeScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(items: [ChatItem]) {
|
func update(items: [ChatItem]) {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
requestedRange = false
|
||||||
snapshot.appendSections([.main])
|
var snapshot = NSDiffableDataSourceSnapshot<ChatSection, ChatItem>()
|
||||||
snapshot.appendItems(items)
|
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.defaultRowAnimation = .none
|
||||||
dataSource.apply(
|
dataSource.apply(
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -205,6 +260,15 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
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()
|
updateFloatingButtons.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +276,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||||
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||||
|
|
||||||
let topItemDate: Date? =
|
let topItemDate: Date? =
|
||||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }),
|
||||||
representer.items[lastVisible.item].meta.itemTs
|
let cI = self.dataSource.itemIdentifier(for: lastVisible) {
|
||||||
|
cI.meta.itemTs
|
||||||
} else {
|
} else {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
let bottomItemId: ChatItem.ID? =
|
let bottomItemId: ChatItem.ID? =
|
||||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }),
|
||||||
representer.items[firstVisible.item].id
|
let cI = self.dataSource.itemIdentifier(for: firstVisible) {
|
||||||
|
cI.id
|
||||||
} else {
|
} else {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
@@ -238,6 +305,40 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
|||||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||||
} else { false }
|
} 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
|
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||||
@@ -292,7 +393,7 @@ class ReverseListScrollModel: ObservableObject {
|
|||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
enum Destination: Equatable {
|
enum Destination: Equatable {
|
||||||
case nextPage
|
case nextPage
|
||||||
case item(ChatItem.ID)
|
case item(ChatItem.ID, UITableView.ScrollPosition, Bool)
|
||||||
case bottom
|
case bottom
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,8 +411,177 @@ class ReverseListScrollModel: ObservableObject {
|
|||||||
state = .scrollingTo(.bottom)
|
state = .scrollingTo(.bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToItem(id: ChatItem.ID) {
|
func scrollToItem(id: ChatItem.ID, position: UITableView.ScrollPosition? = .bottom, animated: Bool = true) {
|
||||||
state = .scrollingTo(.item(id))
|
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,
|
if let jApiChat = jResp["apiChat"] as? NSDictionary,
|
||||||
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
|
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
|
||||||
let jChat = jApiChat["chat"] as? NSDictionary,
|
let jChat = jApiChat["chat"] as? NSDictionary,
|
||||||
let chat = try? parseChatData(jChat) {
|
let jSection = jApiChat["section"] as? String,
|
||||||
return .apiChat(user: user, chat: chat)
|
let chat = try? parseChatData(jChat),
|
||||||
|
let section = ChatLandingSection(rawValue: jSection) {
|
||||||
|
return .apiChat(user: user, chat: chat, section: section)
|
||||||
}
|
}
|
||||||
} else if type == "chatCmdError" {
|
} else if type == "chatCmdError" {
|
||||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case chatStopped
|
case chatStopped
|
||||||
case chatSuspended
|
case chatSuspended
|
||||||
case apiChats(user: UserRef, chats: [ChatData])
|
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 chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||||
case userProtoServers(user: UserRef, servers: UserProtoServers)
|
case userProtoServers(user: UserRef, servers: UserProtoServers)
|
||||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||||
@@ -888,7 +888,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case .chatStopped: return noDetails
|
case .chatStopped: return noDetails
|
||||||
case .chatSuspended: return noDetails
|
case .chatSuspended: return noDetails
|
||||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
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 .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 .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))")
|
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 last(count: Int)
|
||||||
case after(chatItemId: Int64, count: Int)
|
case after(chatItemId: Int64, count: Int)
|
||||||
case before(chatItemId: Int64, count: Int)
|
case before(chatItemId: Int64, count: Int)
|
||||||
|
case around(chatItemId: Int64, count: Int)
|
||||||
|
case initial(count: Int)
|
||||||
|
|
||||||
var cmdString: String {
|
var cmdString: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .last(count): return "count=\(count)"
|
case let .last(count): return "count=\(count)"
|
||||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||||
case let .before(chatItemId, count): return "before=\(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 {
|
public enum ContactStatus: String, Decodable, Hashable {
|
||||||
case active = "active"
|
case active = "active"
|
||||||
case deleted = "deleted"
|
case deleted = "deleted"
|
||||||
|
|||||||
Reference in New Issue
Block a user