mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-03-14 09:45:42 +00:00
Merge branch 'master' into f/super-peer-rfc
This commit is contained in:
commit
a6010fb7f4
45 changed files with 2602 additions and 873 deletions
|
@ -53,7 +53,11 @@ class ItemsModel: ObservableObject {
|
|||
var itemAdded = false {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
|
||||
|
||||
// set listener here that will be notified on every add/delete of a chat item
|
||||
let chatState = ActiveChatState()
|
||||
var chatItemsChangesListener: RecalculatePositions = RecalculatePositions()
|
||||
|
||||
// Publishes directly to `objectWillChange` publisher,
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
|
@ -83,18 +87,16 @@ class ItemsModel: ObservableObject {
|
|||
} catch {}
|
||||
}
|
||||
Task {
|
||||
if let chat = ChatModel.shared.getChat(chatId) {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chat: chat)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chatId: chatId)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
// ChatModel.shared.chatId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -546,6 +548,7 @@ final class ChatModel: ObservableObject {
|
|||
ci.meta.itemStatus = status
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
return true
|
||||
|
@ -591,8 +594,9 @@ final class ChatModel: ObservableObject {
|
|||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = withAnimation {
|
||||
im.reversedChatItems.remove(at: i)
|
||||
withAnimation {
|
||||
let item = im.reversedChatItems.remove(at: i)
|
||||
im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -641,6 +645,7 @@ final class ChatModel: ObservableObject {
|
|||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||
withAnimation {
|
||||
im.reversedChatItems.insert(cItem, at: 0)
|
||||
im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0)
|
||||
im.itemAdded = true
|
||||
}
|
||||
return cItem
|
||||
|
@ -660,71 +665,23 @@ final class ChatModel: ObservableObject {
|
|||
im.reversedChatItems.first?.isLiveDummy == true
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
func markAllChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
_updateChat(cInfo.id) { chat in
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
|
||||
self.updateFloatingButtons(unreadCount: 0)
|
||||
ChatTagsModel.shared.markChatTagRead(chat)
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
markCurrentChatRead()
|
||||
}
|
||||
}
|
||||
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < im.reversedChatItems.count {
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFloatingButtons(unreadCount: Int) {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.totalUnread = unreadCount
|
||||
fbm.objectWillChange.send()
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markCurrentChatRead(fromIndex: i)
|
||||
_updateChat(cInfo.id) { chat in
|
||||
var unreadBelow = 0
|
||||
var unreadMentionsBelow = 0
|
||||
var j = i - 1
|
||||
while j >= 0 {
|
||||
let meta = self.im.reversedChatItems[j].meta
|
||||
if case .rcvNew = meta.itemStatus {
|
||||
unreadBelow += 1
|
||||
if meta.userMention {
|
||||
unreadMentionsBelow += 1
|
||||
}
|
||||
}
|
||||
j -= 1
|
||||
}
|
||||
// update preview
|
||||
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
||||
let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow
|
||||
if markedCount > 0 || markedMentionsCount > 0 {
|
||||
let wasUnread = chat.unreadTag
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
chat.chatStats.unreadMentions -= markedMentionsCount
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
||||
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: by)
|
||||
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||
}
|
||||
}
|
||||
var i = 0
|
||||
while i < im.reversedChatItems.count {
|
||||
markChatItemRead_(i)
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
markChatItemsRead(cInfo)
|
||||
im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
|
||||
_updateChat(cInfo.id) { chat in
|
||||
let wasUnread = chat.unreadTag
|
||||
|
@ -746,16 +703,25 @@ final class ChatModel: ObservableObject {
|
|||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
im.reversedChatItems = []
|
||||
im.chatItemsChangesListener.cleared()
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
|
||||
if self.chatId == cInfo.id {
|
||||
for itemId in itemIds {
|
||||
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
||||
var unreadItemIds: Set<ChatItem.ID> = []
|
||||
var i = 0
|
||||
var ids = Set(itemIds)
|
||||
while i < im.reversedChatItems.count && !ids.isEmpty {
|
||||
let item = im.reversedChatItems[i]
|
||||
if ids.contains(item.id) && item.isRcvNew {
|
||||
markChatItemRead_(i)
|
||||
unreadItemIds.insert(item.id)
|
||||
ids.remove(item.id)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed())
|
||||
}
|
||||
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
|
||||
}
|
||||
|
@ -783,9 +749,6 @@ final class ChatModel: ObservableObject {
|
|||
}
|
||||
|
||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
|
||||
if chatId == ChatModel.shared.chatId {
|
||||
ChatView.FloatingButtonModel.shared.totalUnread += count
|
||||
}
|
||||
let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
|
||||
self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
|
||||
subject.send()
|
||||
|
@ -979,12 +942,17 @@ final class ChatModel: ObservableObject {
|
|||
|
||||
// returns the previous member in the same merge group and the count of members in this group
|
||||
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
|
||||
let items = im.reversedChatItems
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
for i in range {
|
||||
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
if i < items.count {
|
||||
if case let .groupRcv(m) = items[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
} else {
|
||||
logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count)")
|
||||
}
|
||||
}
|
||||
return (prevMember, memberIds.count)
|
||||
|
|
|
@ -328,38 +328,27 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] {
|
|||
|
||||
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) }
|
||||
func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
|
||||
let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
|
||||
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
|
||||
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 }
|
||||
throw r
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
await loadChat(chatId: chat.chatInfo.id)
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
if clearItems {
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async {
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
if clearItems {
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = []
|
||||
ItemsModel.shared.chatItemsChangesListener.cleared()
|
||||
}
|
||||
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
if (replaceChat) {
|
||||
m.replaceChat(chat.chatInfo.id, chat)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 })
|
||||
}
|
||||
|
||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||
|
@ -869,7 +858,7 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
|
|||
|
||||
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
|
||||
|
||||
|
||||
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
|
||||
throw r
|
||||
}
|
||||
|
@ -1524,7 +1513,7 @@ func markChatRead(_ chat: Chat) async {
|
|||
let cInfo = chat.chatInfo
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
|
||||
withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
|
||||
}
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
|
@ -2172,7 +2161,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||
}
|
||||
case let .groupLinkConnecting(user, groupInfo, hostMember):
|
||||
if !active(user) { return }
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
if let hostConn = hostMember.activeConn {
|
||||
|
|
|
@ -684,17 +684,23 @@ struct ChatTTLOption: View {
|
|||
) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await loadChat(chat: chat, clearItems: true)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
currentChatItemTTL = chatItemTTL
|
||||
if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id,
|
||||
let chat = m.getChat(chat.id) {
|
||||
chat.chatItems = []
|
||||
m.replaceChat(chat.id, chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch let error {
|
||||
logger.error("setChatTTL error \(responseError(error))")
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await loadChat(chat: chat, clearItems: true)
|
||||
await MainActor.run {
|
||||
chatItemTTL = currentChatItemTTL
|
||||
progressIndicator = false
|
||||
|
|
|
@ -293,16 +293,16 @@ struct CIFileView_Previews: PreviewProvider {
|
|||
file: nil
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
|
|
|
@ -12,6 +12,7 @@ import SimpleXChat
|
|||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let chatItem: ChatItem
|
||||
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
var imgWidth: CGFloat?
|
||||
|
@ -25,7 +26,7 @@ struct CIImageView: View {
|
|||
if let uiImage = getLoadedImage(file) {
|
||||
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
|
||||
}
|
||||
.if(!smallView) { view in
|
||||
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||
|
|
|
@ -498,10 +498,10 @@ struct CIVoiceView_Previews: PreviewProvider {
|
|||
duration: 30,
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
|
|
@ -92,11 +92,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
|||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -13,8 +13,8 @@ import AVKit
|
|||
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||
@State var chatItem: ChatItem
|
||||
var scrollToItemId: ((ChatItem.ID) -> Void)?
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
@State var url: URL? = nil
|
||||
|
@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
|
|||
let w = abs(t.width)
|
||||
if t.height > 60 && t.height > w * 2 {
|
||||
showView = false
|
||||
scrollModel.scrollToItem(id: chatItem.id)
|
||||
scrollToItemId?(chatItem.id)
|
||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||
let previous = t.width > 0
|
||||
scrolling = true
|
||||
|
|
|
@ -35,18 +35,21 @@ struct ChatItemView: View {
|
|||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
var chatItem: ChatItem
|
||||
var scrollToItemId: (ChatItem.ID) -> Void
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
scrollToItemId: @escaping (ChatItem.ID) -> Void,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
allowMenu: Binding<Bool> = .constant(false)
|
||||
) {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.scrollToItemId = scrollToItemId
|
||||
self.maxWidth = maxWidth
|
||||
_allowMenu = allowMenu
|
||||
}
|
||||
|
@ -90,6 +93,7 @@ struct ChatItemView: View {
|
|||
return FramedItemView(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
scrollToItemId: scrollToItemId,
|
||||
preview: preview,
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
|
@ -244,15 +248,15 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
|||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
|
@ -272,7 +276,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -282,7 +287,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -292,7 +298,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -302,7 +309,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -312,7 +320,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: ciFeatureContent,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
}
|
||||
.environment(\.revealed, true)
|
||||
|
|
329
apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
Normal file
329
apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
Normal file
|
@ -0,0 +1,329 @@
|
|||
//
|
||||
// ChatItemsLoader.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 17.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
let TRIM_KEEP_COUNT = 200
|
||||
|
||||
func apiLoadMessages(
|
||||
_ chatId: ChatId,
|
||||
_ pagination: ChatPagination,
|
||||
_ chatState: ActiveChatState,
|
||||
_ search: String = "",
|
||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
|
||||
) async {
|
||||
let chat: Chat
|
||||
let navInfo: NavigationInfo
|
||||
do {
|
||||
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
|
||||
} catch let error {
|
||||
logger.error("apiLoadMessages error: \(responseError(error))")
|
||||
return
|
||||
}
|
||||
|
||||
let chatModel = ChatModel.shared
|
||||
|
||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||
let paginationIsInitial = switch pagination { case .initial: true; default: false }
|
||||
let paginationIsLast = switch pagination { case .last: true; default: false }
|
||||
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) {
|
||||
return
|
||||
}
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
|
||||
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
|
||||
var newItems: [ChatItem] = []
|
||||
switch pagination {
|
||||
case .initial:
|
||||
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
|
||||
if chatModel.getChat(chat.id) == nil {
|
||||
chatModel.addChat(chat)
|
||||
}
|
||||
await MainActor.run {
|
||||
chatModel.chatItemStatuses.removeAll()
|
||||
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatModel.chatId = chat.chatInfo.id
|
||||
chatState.splits = newSplits
|
||||
if !chat.chatItems.isEmpty {
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
}
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
}
|
||||
case let .before(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
let wasSize = newItems.count
|
||||
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
|
||||
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
|
||||
)
|
||||
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
|
||||
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = modifiedSplits.newSplits
|
||||
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
case let .after(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
|
||||
let mappedItems = mapItemsToIds(chat.chatItems)
|
||||
let newIds = mappedItems.0
|
||||
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
|
||||
)
|
||||
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
|
||||
let indexToAddIsLast = indexToAdd == newItems.count
|
||||
newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
|
||||
let new: [ChatItem] = newItems
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = newSplits
|
||||
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
if indexToAddIsLast {
|
||||
chatState.unreadAfterNewestLoaded -= unreadInLoaded
|
||||
}
|
||||
}
|
||||
case .around:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = [chat.chatItems.last!.id] + newSplits
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
// no need to set it, count will be wrong
|
||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
}
|
||||
case .last:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
removeDuplicates(&newItems, chat)
|
||||
newItems.append(contentsOf: chat.chatItems)
|
||||
let items = newItems
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = items.reversed()
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatState.unreadAfterNewestLoaded = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class ModifiedSplits {
|
||||
let oldUnreadSplitIndex: Int
|
||||
let newUnreadSplitIndex: Int
|
||||
let trimmedIds: Set<Int64>
|
||||
let newSplits: [Int64]
|
||||
|
||||
init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set<Int64>, newSplits: [Int64]) {
|
||||
self.oldUnreadSplitIndex = oldUnreadSplitIndex
|
||||
self.newUnreadSplitIndex = newUnreadSplitIndex
|
||||
self.trimmedIds = trimmedIds
|
||||
self.newSplits = newSplits
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
_ unreadAfterItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexes: ClosedRange<Int>
|
||||
) -> ModifiedSplits {
|
||||
var oldUnreadSplitIndex: Int = -1
|
||||
var newUnreadSplitIndex: Int = -1
|
||||
var lastSplitIndexTrimmed: Int? = nil
|
||||
var allowedTrimming = true
|
||||
var index = 0
|
||||
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
|
||||
let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT
|
||||
let trimUpperBound = newItems.count - TRIM_KEEP_COUNT
|
||||
let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1
|
||||
var trimmedIds = Set<Int64>()
|
||||
let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1
|
||||
let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT
|
||||
let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1
|
||||
var newSplits = splits
|
||||
|
||||
newItems.removeAll(where: {
|
||||
let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
|
||||
let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
|
||||
// may disable it after clearing the whole split range
|
||||
if !splits.isEmpty && $0.id == splits.first {
|
||||
// trim only in one split range
|
||||
allowedTrimming = false
|
||||
}
|
||||
let indexInSplits = splits.firstIndex(of: $0.id)
|
||||
if let indexInSplits {
|
||||
lastSplitIndexTrimmed = indexInSplits
|
||||
}
|
||||
if invisibleItemToTrim {
|
||||
if prevItemWasTrimmed {
|
||||
trimmedIds.insert($0.id)
|
||||
} else {
|
||||
newUnreadSplitIndex = index
|
||||
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
|
||||
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
|
||||
if let lastSplitIndexTrimmed {
|
||||
var new = newSplits
|
||||
new[lastSplitIndexTrimmed] = $0.id
|
||||
newSplits = new
|
||||
} else {
|
||||
newSplits = [$0.id] + newSplits
|
||||
}
|
||||
}
|
||||
}
|
||||
if unreadAfterItemId == $0.id {
|
||||
oldUnreadSplitIndex = index
|
||||
}
|
||||
index += 1
|
||||
return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id)
|
||||
})
|
||||
// will remove any splits that now becomes obsolete because items were merged
|
||||
newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) }
|
||||
return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
_ unreadInLoaded: Int,
|
||||
_ paginationChatItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64]
|
||||
) -> ([Int64], Int) {
|
||||
var unreadInLoaded = unreadInLoaded
|
||||
var firstItemIdBelowAllSplits: Int64? = nil
|
||||
var splitsToRemove: Set<Int64> = []
|
||||
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
||||
// Currently, it should always load from split range
|
||||
let loadingFromSplitRange = indexInSplitRanges != nil
|
||||
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
||||
Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
newItems.removeAll(where: { new in
|
||||
let duplicate = newIds.contains(new.id)
|
||||
if loadingFromSplitRange && duplicate {
|
||||
if splitsToMerge.contains(new.id) {
|
||||
splitsToMerge.removeAll(where: { $0 == new.id })
|
||||
splitsToRemove.insert(new.id)
|
||||
} else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty {
|
||||
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
|
||||
firstItemIdBelowAllSplits = new.id
|
||||
}
|
||||
}
|
||||
if duplicate && new.isRcvNew {
|
||||
unreadInLoaded -= 1
|
||||
}
|
||||
return duplicate
|
||||
})
|
||||
var newSplits: [Int64] = []
|
||||
if firstItemIdBelowAllSplits != nil {
|
||||
// no splits anymore, all were merged with bottom items
|
||||
newSplits = []
|
||||
} else {
|
||||
if !splitsToRemove.isEmpty {
|
||||
var new = splits
|
||||
new.removeAll(where: { splitsToRemove.contains($0) })
|
||||
newSplits = new
|
||||
}
|
||||
let enlargedSplit = splits.firstIndex(of: paginationChatItemId)
|
||||
if let enlargedSplit {
|
||||
// move the split to the end of loaded items
|
||||
var new = splits
|
||||
new[enlargedSplit] = chat.chatItems.last!.id
|
||||
newSplits = new
|
||||
}
|
||||
}
|
||||
return (newSplits, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndUpperSplits(
|
||||
_ newItems: inout [ChatItem],
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int>
|
||||
) async -> [Int64] {
|
||||
if splits.isEmpty {
|
||||
removeDuplicates(&newItems, chat)
|
||||
return splits
|
||||
}
|
||||
|
||||
var newSplits = splits
|
||||
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
var idsToTrim: [BoxedValue<Set<Int64>>] = []
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
var index = 0
|
||||
newItems.removeAll(where: {
|
||||
let duplicate = newIds.contains($0.id)
|
||||
if (!duplicate && visibleItemIndexes.lowerBound > index) {
|
||||
idsToTrim.last?.boxedValue.insert($0.id)
|
||||
}
|
||||
if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) {
|
||||
newSplits.remove(at: firstIndex)
|
||||
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
|
||||
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
}
|
||||
|
||||
index += 1
|
||||
return duplicate
|
||||
})
|
||||
if !idsToTrim.last!.boxedValue.isEmpty {
|
||||
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
|
||||
// Otherwise, the last set would be empty
|
||||
idsToTrim.removeLast()
|
||||
}
|
||||
let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined()
|
||||
if !allItemsToDelete.isEmpty {
|
||||
newItems.removeAll(where: { allItemsToDelete.contains($0.id) })
|
||||
}
|
||||
return newSplits
|
||||
}
|
||||
|
||||
// ids, number of unread items
|
||||
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
||||
var unreadInLoaded = 0
|
||||
var ids: Set<Int64> = Set()
|
||||
var i = 0
|
||||
while i < items.count {
|
||||
let item = items[i]
|
||||
ids.insert(item.id)
|
||||
if item.isRcvNew {
|
||||
unreadInLoaded += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return (ids, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll { newIds.contains($0.id) }
|
||||
}
|
459
apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
Normal file
459
apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
Normal file
|
@ -0,0 +1,459 @@
|
|||
//
|
||||
// ChatItemsMerger.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 02.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct MergedItems: Hashable, Equatable {
|
||||
let items: [MergedItem]
|
||||
let splits: [SplitRange]
|
||||
// chat item id, index in list
|
||||
let indexInParentItems: Dictionary<Int64, Int>
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("\(items.hashValue)")
|
||||
}
|
||||
|
||||
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
|
||||
if items.isEmpty {
|
||||
return MergedItems(items: [], splits: [], indexInParentItems: [:])
|
||||
}
|
||||
|
||||
let unreadCount = chatState.unreadTotal
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
let itemSplits = chatState.splits
|
||||
var mergedItems: [MergedItem] = []
|
||||
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
|
||||
var splitRanges: [SplitRange] = []
|
||||
var indexInParentItems = Dictionary<Int64, Int>()
|
||||
var index = 0
|
||||
var unclosedSplitIndex: Int? = nil
|
||||
var unclosedSplitIndexInParent: Int? = nil
|
||||
var visibleItemIndexInParent = -1
|
||||
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
|
||||
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
|
||||
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
|
||||
var recent: MergedItem? = nil
|
||||
while index < items.count {
|
||||
let item = items[index]
|
||||
let prev = index >= 1 ? items[index - 1] : nil
|
||||
let next = index + 1 < items.count ? items[index + 1] : nil
|
||||
let category = item.mergeCategory
|
||||
let itemIsSplit = itemSplits.contains(item.id)
|
||||
|
||||
if item.id == unreadAfterItemId {
|
||||
unreadBefore = unreadCount - chatState.unreadAfter
|
||||
}
|
||||
if item.isRcvNew {
|
||||
unreadBefore -= 1
|
||||
}
|
||||
|
||||
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
|
||||
if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
items.boxedValue.append(listItem)
|
||||
|
||||
if item.isRcvNew {
|
||||
unreadIds.boxedValue.insert(item.id)
|
||||
}
|
||||
if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
|
||||
if revealed {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
|
||||
}
|
||||
} else {
|
||||
visibleItemIndexInParent += 1
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
if item.mergeCategory != nil {
|
||||
if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
|
||||
lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : [])
|
||||
} else if revealed, let lastRevealedIdsInMergedItems {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems = BoxedValue(index ... index)
|
||||
recent = MergedItem.grouped(
|
||||
items: BoxedValue([listItem]),
|
||||
revealed: revealed,
|
||||
revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
|
||||
rangeInReversed: lastRangeInReversedForMergedItems!,
|
||||
mergeCategory: item.mergeCategory,
|
||||
unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()),
|
||||
startIndexInReversedItems: index,
|
||||
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
|
||||
)
|
||||
} else {
|
||||
lastRangeInReversedForMergedItems = nil
|
||||
recent = MergedItem.single(
|
||||
item: listItem,
|
||||
startIndexInReversedItems: index,
|
||||
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
|
||||
)
|
||||
}
|
||||
mergedItems.append(recent!)
|
||||
}
|
||||
if itemIsSplit {
|
||||
// found item that is considered as a split
|
||||
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// it was at least second split in the list
|
||||
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
|
||||
}
|
||||
unclosedSplitIndex = index
|
||||
unclosedSplitIndexInParent = visibleItemIndexInParent
|
||||
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// just one split for the whole list, there will be no more, it's the end
|
||||
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
|
||||
}
|
||||
indexInParentItems[item.id] = visibleItemIndexInParent
|
||||
index += 1
|
||||
}
|
||||
return MergedItems(
|
||||
items: mergedItems,
|
||||
splits: splitRanges,
|
||||
indexInParentItems: indexInParentItems
|
||||
)
|
||||
}
|
||||
|
||||
// Use this check to ensure that mergedItems state based on currently actual state of global
|
||||
// splits and reversedChatItems
|
||||
func isActualState() -> Bool {
|
||||
let im = ItemsModel.shared
|
||||
// do not load anything if global splits state is different than in merged items because it
|
||||
// will produce undefined results in terms of loading and placement of items.
|
||||
// Same applies to reversedChatItems
|
||||
return indexInParentItems.count == im.reversedChatItems.count &&
|
||||
splits.count == im.chatState.splits.count &&
|
||||
// that's just an optimization because most of the time only 1 split exists
|
||||
((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum MergedItem: Identifiable, Hashable, Equatable {
|
||||
// equatable and hashable implementations allows to see the difference and correctly scroll to items we want
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.hash == rhs.hash
|
||||
}
|
||||
|
||||
var id: Int64 { newest().item.id }
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(hash)
|
||||
}
|
||||
|
||||
var hash: String {
|
||||
switch self {
|
||||
case .single(_, _, let hash): hash + " 1"
|
||||
case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)"
|
||||
}
|
||||
}
|
||||
|
||||
// the item that is always single, cannot be grouped and always revealed
|
||||
case single(
|
||||
item: ListItem,
|
||||
startIndexInReversedItems: Int,
|
||||
hash: String
|
||||
)
|
||||
|
||||
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
|
||||
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
|
||||
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
|
||||
* visible items in ChatView's EndlessScrollView */
|
||||
case grouped (
|
||||
items: BoxedValue<[ListItem]>,
|
||||
revealed: Bool,
|
||||
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
|
||||
// it's the same list instance for all Grouped items within revealed group
|
||||
/** @see reveal */
|
||||
revealedIdsWithinGroup: BoxedValue<[Int64]>,
|
||||
rangeInReversed: BoxedValue<ClosedRange<Int>>,
|
||||
mergeCategory: CIMergeCategory?,
|
||||
unreadIds: BoxedValue<Set<Int64>>,
|
||||
startIndexInReversedItems: Int,
|
||||
hash: String
|
||||
)
|
||||
|
||||
func revealItems(_ reveal: Bool, _ revealedItems: Binding<Set<Int64>>) {
|
||||
if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self {
|
||||
var newRevealed = revealedItems.wrappedValue
|
||||
var i = 0
|
||||
if reveal {
|
||||
while i < items.boxedValue.count {
|
||||
newRevealed.insert(items.boxedValue[i].item.id)
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
while i < revealedIdsWithinGroup.boxedValue.count {
|
||||
newRevealed.remove(revealedIdsWithinGroup.boxedValue[i])
|
||||
i += 1
|
||||
}
|
||||
revealedIdsWithinGroup.boxedValue.removeAll()
|
||||
}
|
||||
revealedItems.wrappedValue = newRevealed
|
||||
}
|
||||
}
|
||||
|
||||
var startIndexInReversedItems: Int {
|
||||
get {
|
||||
switch self {
|
||||
case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems
|
||||
case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnread() -> Bool {
|
||||
switch self {
|
||||
case let .single(item, _, _): item.item.isRcvNew
|
||||
case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func newest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0]
|
||||
}
|
||||
}
|
||||
|
||||
func oldest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1]
|
||||
}
|
||||
}
|
||||
|
||||
func lastIndexInReversed() -> Int {
|
||||
switch self {
|
||||
case .single: startIndexInReversedItems
|
||||
case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitRange {
|
||||
let itemId: Int64
|
||||
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
|
||||
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
|
||||
* (3, 4 indexes of the splitRange with the split itself at index 3)
|
||||
* */
|
||||
let indexRangeInReversed: ClosedRange<Int>
|
||||
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
|
||||
let indexRangeInParentItems: ClosedRange<Int>
|
||||
}
|
||||
|
||||
struct ListItem: Hashable {
|
||||
let item: ChatItem
|
||||
let prevItem: ChatItem?
|
||||
let nextItem: ChatItem?
|
||||
// how many unread items before (older than) this one (excluding this one)
|
||||
let unreadBefore: Int
|
||||
|
||||
private func chatDirHash(_ chatDir: CIDirection?) -> Int {
|
||||
guard let chatDir else { return 0 }
|
||||
return switch chatDir {
|
||||
case .directSnd: 0
|
||||
case .directRcv: 1
|
||||
case .groupSnd: 2
|
||||
case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
|
||||
case .localSnd: 4
|
||||
case .localRcv: 5
|
||||
}
|
||||
}
|
||||
|
||||
// using meta.hashValue instead of parts takes much more time so better to use partial meta here
|
||||
func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String {
|
||||
"\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)"
|
||||
}
|
||||
}
|
||||
|
||||
class ActiveChatState {
|
||||
var splits: [Int64] = []
|
||||
var unreadAfterItemId: Int64 = -1
|
||||
// total items after unread after item (exclusive)
|
||||
var totalAfter: Int = 0
|
||||
var unreadTotal: Int = 0
|
||||
// exclusive
|
||||
var unreadAfter: Int = 0
|
||||
// exclusive
|
||||
var unreadAfterNewestLoaded: Int = 0
|
||||
|
||||
func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) {
|
||||
guard let toItemId else { return }
|
||||
let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId })
|
||||
let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId })
|
||||
guard let currentIndex, let newIndex else {
|
||||
return
|
||||
}
|
||||
unreadAfterItemId = toItemId
|
||||
let unreadDiff = newIndex > currentIndex
|
||||
? -nonReversedItems[currentIndex + 1..<newIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[newIndex + 1..<currentIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func moveUnreadAfterItem(_ fromIndex: Int, _ toIndex: Int, _ nonReversedItems: [ChatItem]) {
|
||||
if fromIndex == -1 || toIndex == -1 {
|
||||
return
|
||||
}
|
||||
unreadAfterItemId = nonReversedItems[toIndex].id
|
||||
let unreadDiff = toIndex > fromIndex
|
||||
? -nonReversedItems[fromIndex + 1..<toIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[toIndex + 1..<fromIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func clear() {
|
||||
splits = []
|
||||
unreadAfterItemId = -1
|
||||
totalAfter = 0
|
||||
unreadTotal = 0
|
||||
unreadAfter = 0
|
||||
unreadAfterNewestLoaded = 0
|
||||
}
|
||||
}
|
||||
|
||||
class BoxedValue<T: Hashable>: Equatable, Hashable {
|
||||
static func == (lhs: BoxedValue<T>, rhs: BoxedValue<T>) -> Bool {
|
||||
lhs.boxedValue == rhs.boxedValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("\(self)")
|
||||
}
|
||||
|
||||
var boxedValue : T
|
||||
init(_ value: T) {
|
||||
self.boxedValue = value
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
|
||||
let zero = 0 ... 0
|
||||
let items = mergedItems.items
|
||||
if items.isEmpty {
|
||||
return zero
|
||||
}
|
||||
let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
|
||||
let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
|
||||
guard let newest, let oldest else {
|
||||
return zero
|
||||
}
|
||||
let size = ItemsModel.shared.reversedChatItems.count
|
||||
let range = size - oldest ... size - newest
|
||||
if range.lowerBound < 0 || range.upperBound < 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
|
||||
return range
|
||||
}
|
||||
|
||||
class RecalculatePositions {
|
||||
private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } }
|
||||
|
||||
func read(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
|
||||
guard let itemIds else {
|
||||
// special case when the whole chat became read
|
||||
chatState.unreadTotal = 0
|
||||
chatState.unreadAfter = 0
|
||||
return
|
||||
}
|
||||
var unreadAfterItemIndex: Int = -1
|
||||
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
|
||||
var i = newItems.count - 1
|
||||
var ids = itemIds
|
||||
// intermediate variables to prevent re-setting state value a lot of times without reason
|
||||
var newUnreadTotal = chatState.unreadTotal
|
||||
var newUnreadAfter = chatState.unreadAfter
|
||||
while i >= 0 {
|
||||
let item = newItems[i]
|
||||
if item.id == chatState.unreadAfterItemId {
|
||||
unreadAfterItemIndex = i
|
||||
}
|
||||
if ids.contains(item.id) {
|
||||
// was unread, now this item is read
|
||||
if (unreadAfterItemIndex == -1) {
|
||||
newUnreadAfter -= 1
|
||||
}
|
||||
newUnreadTotal -= 1
|
||||
ids.remove(item.id)
|
||||
if ids.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
chatState.unreadTotal = newUnreadTotal
|
||||
chatState.unreadAfter = newUnreadAfter
|
||||
}
|
||||
func added(_ item: (Int64, Bool), _ index: Int) {
|
||||
if item.1 {
|
||||
chatState.unreadAfter += 1
|
||||
chatState.unreadTotal += 1
|
||||
}
|
||||
}
|
||||
func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
|
||||
var newSplits: [Int64] = []
|
||||
for split in chatState.splits {
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
|
||||
// deleted the item that was right before the split between items, find newer item so it will act like the split
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
|
||||
// it the whole section is gone and splits overlap, don't add it at all
|
||||
if let newSplit, !newSplits.contains(newSplit) {
|
||||
newSplits.append(newSplit)
|
||||
}
|
||||
} else {
|
||||
newSplits.append(split)
|
||||
}
|
||||
}
|
||||
chatState.splits = newSplits
|
||||
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId })
|
||||
// unread after item was removed
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
|
||||
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
|
||||
if newUnreadAfterItemId == nil {
|
||||
// everything on top (including unread after item) were deleted, take top item as unread after id
|
||||
newUnreadAfterItemId = newItems.first?.id
|
||||
}
|
||||
if let newUnreadAfterItemId {
|
||||
chatState.unreadAfterItemId = newUnreadAfterItemId
|
||||
chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
|
||||
chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
|
||||
chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
|
||||
if newUnreadAfterItemWasNull {
|
||||
// since the unread after item was moved one item after initial position, adjust counters accordingly
|
||||
if newItems.first?.isRcvNew == true {
|
||||
chatState.unreadTotal += 1
|
||||
chatState.unreadAfter -= 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// all items were deleted, 0 items in chatItems
|
||||
chatState.unreadAfterItemId = -1
|
||||
chatState.totalAfter = 0
|
||||
chatState.unreadTotal = 0
|
||||
chatState.unreadAfter = 0
|
||||
}
|
||||
} else {
|
||||
chatState.totalAfter -= itemIds.count
|
||||
}
|
||||
}
|
||||
func cleared() { chatState.clear() }
|
||||
}
|
163
apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
Normal file
163
apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
Normal file
|
@ -0,0 +1,163 @@
|
|||
//
|
||||
// ChatScrollHelpers.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 20.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
func loadLastItems(_ loadingMoreItems: Binding<Bool>, _ chat: Chat) {
|
||||
if ItemsModel.shared.chatState.totalAfter == 0 {
|
||||
return
|
||||
}
|
||||
loadingMoreItems.wrappedValue = true
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1500_000000)
|
||||
if ChatModel.shared.chatId != chat.chatInfo.id {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PreloadState {
|
||||
static let shared = PreloadState()
|
||||
var prevFirstVisible: Int64 = Int64.min
|
||||
var prevItemsCount: Int = 0
|
||||
var preloading: Bool = false
|
||||
}
|
||||
|
||||
func preloadIfNeeded(
|
||||
_ allowLoadMoreItems: Binding<Bool>,
|
||||
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||
_ mergedItems: BoxedValue<MergedItems>,
|
||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
||||
) {
|
||||
let state = PreloadState.shared
|
||||
guard !listState.isScrolling,
|
||||
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
||||
!state.preloading,
|
||||
listState.totalItemsCount > 0
|
||||
else {
|
||||
return
|
||||
}
|
||||
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
|
||||
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
|
||||
state.preloading = true
|
||||
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
||||
Task {
|
||||
defer {
|
||||
state.preloading = false
|
||||
}
|
||||
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
|
||||
await loadItems(false, pagination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preloadItems(
|
||||
_ mergedItems: MergedItems,
|
||||
_ allowLoadMoreItems: Bool,
|
||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||
_ loadItems: @escaping (ChatPagination) async -> Bool)
|
||||
async {
|
||||
let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1
|
||||
let remaining = ChatPagination.UNTIL_PRELOAD_COUNT
|
||||
let firstVisibleIndex = listState.firstVisibleItemIndex
|
||||
|
||||
if !(await preloadItemsBefore()) {
|
||||
await preloadItemsAfter()
|
||||
}
|
||||
|
||||
func preloadItemsBefore() async -> Bool {
|
||||
let splits = mergedItems.splits
|
||||
let lastVisibleIndex = listState.lastVisibleItemIndex
|
||||
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
|
||||
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
|
||||
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
|
||||
lastIndexToLoadFrom = items.count - 1
|
||||
}
|
||||
let loadFromItemId: Int64?
|
||||
if allowLoad, let lastIndexToLoadFrom {
|
||||
let index = items.count - 1 - lastIndexToLoadFrom
|
||||
loadFromItemId = index >= 0 ? items[index].id : nil
|
||||
} else {
|
||||
loadFromItemId = nil
|
||||
}
|
||||
guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else {
|
||||
return false
|
||||
}
|
||||
let sizeWas = items.count
|
||||
let firstItemIdWas = items.first?.id
|
||||
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
|
||||
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
|
||||
ignoreLoadingRequests.wrappedValue = loadFromItemId
|
||||
}
|
||||
return triedToLoad
|
||||
}
|
||||
|
||||
func preloadItemsAfter() async {
|
||||
let splits = mergedItems.splits
|
||||
let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
|
||||
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
|
||||
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
|
||||
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
|
||||
let index = split.indexRangeInReversed.lowerBound
|
||||
if index >= 0 {
|
||||
let loadFromItemId = reversedItems[index].id
|
||||
_ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView<MergedItem>.ListState) -> ListItem? {
|
||||
if listState.lastVisibleItemIndex < listState.items.count {
|
||||
return listState.items[listState.lastVisibleItemIndex].oldest()
|
||||
} else {
|
||||
return listState.items.last?.oldest()
|
||||
}
|
||||
}
|
||||
|
||||
private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? {
|
||||
for split in splits {
|
||||
// before any split
|
||||
if split.indexRangeInParentItems.lowerBound > firstVisibleIndex {
|
||||
if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) {
|
||||
return split.indexRangeInReversed.lowerBound - 1
|
||||
}
|
||||
break
|
||||
}
|
||||
let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex)
|
||||
if containsInRange {
|
||||
if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) {
|
||||
return split.indexRangeInReversed.upperBound
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Disable animation on iOS 15
|
||||
func withConditionalAnimation<Result>(
|
||||
_ animation: Animation? = .default,
|
||||
_ body: () throws -> Result
|
||||
) rethrows -> Result {
|
||||
if #available(iOS 16.0, *) {
|
||||
try withAnimation(animation, body)
|
||||
} else {
|
||||
try body()
|
||||
}
|
||||
}
|
|
@ -16,13 +16,14 @@ private let memberImageSize: CGFloat = 34
|
|||
struct ChatView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var im = ItemsModel.shared
|
||||
@State var mergedItems: BoxedValue<MergedItems> = BoxedValue(MergedItems(items: [], splits: [], indexInParentItems: [:]))
|
||||
@State var revealedItems: Set<Int64> = Set()
|
||||
@State var theme: AppTheme = buildTheme()
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State @ObservedObject var chat: Chat
|
||||
@StateObject private var scrollModel = ReverseListScrollModel()
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
|
@ -31,9 +32,7 @@ struct ChatView: View {
|
|||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@State private var connectionCode: String?
|
||||
@State private var loadingItems = false
|
||||
@State private var firstPage = false
|
||||
@State private var revealedChatItem: ChatItem?
|
||||
@State private var loadingMoreItems = false
|
||||
@State private var searchMode = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
@ -48,7 +47,15 @@ struct ChatView: View {
|
|||
@State private var showDeleteSelectedMessages: Bool = false
|
||||
@State private var showArchiveSelectedReports: Bool = false
|
||||
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
|
||||
@State private var allowLoadMoreItems: Bool = false
|
||||
@State private var ignoreLoadingRequests: Int64? = nil
|
||||
@State private var updateMergedItemsTask: Task<Void, Never>? = nil
|
||||
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
|
||||
|
||||
private let useItemsUpdateTask = false
|
||||
|
||||
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
|
||||
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
|
@ -81,7 +88,7 @@ struct ChatView: View {
|
|||
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
|
||||
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
|
||||
}
|
||||
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
|
||||
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, listState: scrollView.listState, model: floatingButtonModel)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
|
@ -190,35 +197,32 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
.onAppear {
|
||||
scrollView.listState.onUpdateListener = onChatItemsUpdated
|
||||
selectedChatItems = nil
|
||||
revealedItems = Set()
|
||||
initChatView()
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
showChatInfoSheet = false
|
||||
selectedChatItems = nil
|
||||
scrollModel.scrollToBottom()
|
||||
revealedItems = Set()
|
||||
stopAudioPlayer()
|
||||
if let cId {
|
||||
if let c = chatModel.getChat(cId) {
|
||||
chat = c
|
||||
}
|
||||
scrollView.listState.onUpdateListener = onChatItemsUpdated
|
||||
initChatView()
|
||||
theme = buildTheme()
|
||||
Task {
|
||||
if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
await scrollView.scrollToItem(unreadIndex, animated: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onChange(of: revealedChatItem) { _ in
|
||||
NotificationCenter.postReverseListNeedsLayout()
|
||||
}
|
||||
.onChange(of: im.isLoading) { isLoading in
|
||||
if !isLoading,
|
||||
im.reversedChatItems.count <= loadItemsPerPage,
|
||||
filtered(im.reversedChatItems).count < 10 {
|
||||
loadChatItems(chat.chatInfo)
|
||||
}
|
||||
}
|
||||
.environmentObject(scrollModel)
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
stopAudioPlayer()
|
||||
|
@ -227,6 +231,7 @@ struct ChatView: View {
|
|||
if chatModel.chatId == nil {
|
||||
chatModel.chatItemStatuses = [:]
|
||||
ItemsModel.shared.reversedChatItems = []
|
||||
ItemsModel.shared.chatItemsChangesListener.cleared()
|
||||
chatModel.groupMembers = []
|
||||
chatModel.groupMembersIndexes.removeAll()
|
||||
chatModel.membersLoaded = false
|
||||
|
@ -393,9 +398,36 @@ struct ChatView: View {
|
|||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
||||
floatingButtonModel.updateOnListChange(scrollView.listState)
|
||||
}
|
||||
|
||||
private func scrollToItemId(_ itemId: ChatItem.ID) {
|
||||
Task {
|
||||
do {
|
||||
var index = mergedItems.boxedValue.indexInParentItems[itemId]
|
||||
if index == nil {
|
||||
let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
|
||||
let oldSize = ItemsModel.shared.reversedChatItems.count
|
||||
let triedToLoad = await loadChatItems(chat, pagination)
|
||||
if !triedToLoad {
|
||||
return
|
||||
}
|
||||
var repeatsLeft = 50
|
||||
while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
|
||||
try await Task.sleep(nanoseconds: 20_000000)
|
||||
repeatsLeft -= 1
|
||||
}
|
||||
index = mergedItems.boxedValue.indexInParentItems[itemId]
|
||||
}
|
||||
if let index {
|
||||
await scrollView.scrollToItem(min(ItemsModel.shared.reversedChatItems.count - 1, index), animated: true)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error scrolling to item: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
|
@ -444,46 +476,117 @@ struct ChatView: View {
|
|||
.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
|
||||
//let _ = logger.debug("Reloading chatItemsList with number of itmes: \(im.reversedChatItems.count)")
|
||||
ScrollRepresentable(scrollView: scrollView) { (index: Int, mergedItem: MergedItem) in
|
||||
let ci = switch mergedItem {
|
||||
case let .single(item, _, _): item.item
|
||||
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue.last!.item
|
||||
}
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
? voiceNoFrame
|
||||
? (g.size.width - 28) - 42
|
||||
: (g.size.width - 28) * 0.84 - 42
|
||||
: voiceNoFrame
|
||||
? (g.size.width - 32)
|
||||
: (g.size.width - 32) * 0.84
|
||||
? voiceNoFrame
|
||||
? (g.size.width - 28) - 42
|
||||
: (g.size.width - 28) * 0.84 - 42
|
||||
: voiceNoFrame
|
||||
? (g.size.width - 32)
|
||||
: (g.size.width - 32) * 0.84
|
||||
return ChatItemWithMenu(
|
||||
chat: $chat,
|
||||
index: index,
|
||||
isLastItem: index == mergedItems.boxedValue.items.count - 1,
|
||||
chatItem: ci,
|
||||
scrollToItemId: scrollToItemId,
|
||||
merged: mergedItem,
|
||||
maxWidth: maxWidth,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
showChatInfoSheet: $showChatInfoSheet,
|
||||
revealedChatItem: $revealedChatItem,
|
||||
revealedItems: $revealedItems,
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
)
|
||||
// crashes on Cell size calculation without this line
|
||||
.environmentObject(ChatModel.shared)
|
||||
.environmentObject(theme) // crashes without this line when scrolling to the first unread in EndlessScrollVIew
|
||||
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
||||
} loadPage: {
|
||||
loadChatItems(cInfo)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||
let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() })
|
||||
let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil }
|
||||
await MainActor.run {
|
||||
// this helps to speed up initial process of setting scroll position and reduce time needed
|
||||
// to layout items on screen
|
||||
if let unreadIndex, let unreadItemId {
|
||||
scrollView.setScrollPosition(unreadIndex, unreadItemId)
|
||||
}
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
if let unreadIndex {
|
||||
await scrollView.scrollToItem(unreadIndex, animated: false)
|
||||
}
|
||||
}
|
||||
loadLastItems($loadingMoreItems, chat)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
}
|
||||
}
|
||||
.onChange(of: im.reversedChatItems) { items in
|
||||
updateMergedItemsTask?.cancel()
|
||||
if useItemsUpdateTask {
|
||||
updateMergedItemsTask = Task {
|
||||
let items = MergedItems.create(items, revealedItems, im.chatState)
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
mergedItems.boxedValue = items
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
}
|
||||
.onChange(of: revealedItems) { revealed in
|
||||
updateMergedItemsTask?.cancel()
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
.onChange(of: chat.id) { _ in
|
||||
loadLastItems($loadingMoreItems, chat)
|
||||
allowLoadMoreItems = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
}
|
||||
}
|
||||
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
||||
.padding(.vertical, -InvertedTableView.inset)
|
||||
.padding(.vertical, -100)
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { _ in
|
||||
Task { await loadChat(chat: chat, search: searchText) }
|
||||
.onChange(of: searchText) { s in
|
||||
Task {
|
||||
await loadChat(chat: chat, search: s)
|
||||
if s.isEmpty {
|
||||
await scrollView.scrollToItem(0, animated: false, top: false)
|
||||
loadLastItems($loadingMoreItems, chat)
|
||||
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
// scroll to the top unread item
|
||||
await scrollView.scrollToItem(index, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
im.itemAdded = false
|
||||
if FloatingButtonModel.shared.isReallyNearBottom {
|
||||
scrollModel.scrollToBottom()
|
||||
if scrollView.listState.firstVisibleItemIndex < 2 {
|
||||
scrollView.scrollToBottom()
|
||||
} else {
|
||||
scrollView.scroll(by: 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -505,30 +608,24 @@ struct ChatView: View {
|
|||
}
|
||||
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
static let shared = FloatingButtonModel()
|
||||
@Published var unreadAbove: Int = 0
|
||||
@Published var unreadBelow: Int = 0
|
||||
@Published var isNearBottom: Bool = true
|
||||
@Published var date: Date?
|
||||
@Published var date: Date? = nil
|
||||
@Published var isDateVisible: Bool = false
|
||||
var totalUnread: Int = 0
|
||||
var isReallyNearBottom: Bool = true
|
||||
var hideDateWorkItem: DispatchWorkItem?
|
||||
var hideDateWorkItem: DispatchWorkItem? = nil
|
||||
|
||||
func updateOnListChange(_ listState: ListState) {
|
||||
let im = ItemsModel.shared
|
||||
let unreadBelow =
|
||||
if let id = listState.bottomItemId,
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
|
||||
{
|
||||
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
func updateOnListChange(_ listState: EndlessScrollView<MergedItem>.ListState) {
|
||||
let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
|
||||
let unreadBelow = if let lastVisibleItem {
|
||||
max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow
|
||||
let date: Date? =
|
||||
if let topItemDate = listState.topItemDate {
|
||||
Calendar.current.startOfDay(for: topItemDate)
|
||||
if let lastVisible = listState.visibleItems.last {
|
||||
Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
@ -537,13 +634,13 @@ struct ChatView: View {
|
|||
DispatchQueue.main.async { [weak self] in
|
||||
guard let it = self else { return }
|
||||
it.setDate(visibility: true)
|
||||
it.unreadAbove = unreadAbove
|
||||
it.unreadBelow = unreadBelow
|
||||
it.date = date
|
||||
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
|
||||
}
|
||||
|
||||
// set floating button indication mode
|
||||
let nearBottom = listState.scrollOffset < 800
|
||||
let nearBottom = listState.firstVisibleItemIndex < 4
|
||||
if nearBottom != self.isNearBottom {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||
self?.isNearBottom = nearBottom
|
||||
|
@ -584,9 +681,11 @@ struct ChatView: View {
|
|||
|
||||
private struct FloatingButtons: View {
|
||||
let theme: AppTheme
|
||||
let scrollModel: ReverseListScrollModel
|
||||
let scrollView: EndlessScrollView<MergedItem>
|
||||
let chat: Chat
|
||||
@ObservedObject var model = FloatingButtonModel.shared
|
||||
@Binding var loadingMoreItems: Bool
|
||||
let listState: EndlessScrollView<MergedItem>.ListState
|
||||
@ObservedObject var model: FloatingButtonModel
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
|
@ -596,46 +695,63 @@ struct ChatView: View {
|
|||
.background(.thinMaterial)
|
||||
.clipShape(Capsule())
|
||||
.opacity(model.isDateVisible ? 1 : 0)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
VStack {
|
||||
let unreadAbove = model.totalUnread - model.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToNextPage()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
if model.unreadAbove > 0 {
|
||||
if loadingMoreItems {
|
||||
circleButton { ProgressView() }
|
||||
} else {
|
||||
circleButton {
|
||||
unreadCountText(model.unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
// scroll to the top unread item
|
||||
Task { await scrollView.scrollToItem(index, animated: true) }
|
||||
} else {
|
||||
logger.debug("No more unread items, total: \(listState.items.count)")
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if model.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(model.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
if loadingMoreItems {
|
||||
circleButton { ProgressView() }
|
||||
} else {
|
||||
circleButton {
|
||||
unreadCountText(model.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollView.scrollToBottom()
|
||||
}
|
||||
}
|
||||
} else if !model.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
if loadingMoreItems {
|
||||
circleButton { ProgressView() }
|
||||
} else {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down").foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { scrollView.scrollToBottom() }
|
||||
}
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
}
|
||||
}
|
||||
.disabled(loadingMoreItems)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
@ -871,43 +987,27 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo) {
|
||||
Task {
|
||||
if loadingItems || 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 pagination: ChatPagination =
|
||||
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
|
||||
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
||||
} else {
|
||||
.last(count: loadItemsPerPage)
|
||||
}
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: searchText
|
||||
)
|
||||
chatItemsAvailable = !chatItems.isEmpty
|
||||
reversedPage.append(contentsOf: chatItems.reversed())
|
||||
}
|
||||
await MainActor.run {
|
||||
if reversedPage.count == 0 {
|
||||
firstPage = true
|
||||
} else {
|
||||
im.reversedChatItems.append(contentsOf: reversedPage)
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
await MainActor.run { loadingItems = false }
|
||||
}
|
||||
private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
|
||||
if loadingMoreItems { return false }
|
||||
await MainActor.run {
|
||||
loadingMoreItems = true
|
||||
}
|
||||
let triedToLoad = await loadChatItemsUnchecked(chat, pagination)
|
||||
await MainActor.run {
|
||||
loadingMoreItems = false
|
||||
}
|
||||
return triedToLoad
|
||||
}
|
||||
|
||||
private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
|
||||
await apiLoadMessages(
|
||||
chat.chatInfo.id,
|
||||
pagination,
|
||||
im.chatState,
|
||||
searchText,
|
||||
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
func stopAudioPlayer() {
|
||||
|
@ -915,18 +1015,43 @@ struct ChatView: View {
|
|||
VoiceItemState.chatView = [:]
|
||||
}
|
||||
|
||||
func onChatItemsUpdated() {
|
||||
if !mergedItems.boxedValue.isActualState() {
|
||||
//logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)")
|
||||
return
|
||||
}
|
||||
floatingButtonModel.updateOnListChange(scrollView.listState)
|
||||
preloadIfNeeded(
|
||||
$allowLoadMoreItems,
|
||||
$ignoreLoadingRequests,
|
||||
scrollView.listState,
|
||||
mergedItems,
|
||||
loadItems: { unchecked, pagination in
|
||||
if unchecked {
|
||||
await loadChatItemsUnchecked(chat, pagination)
|
||||
} else {
|
||||
await loadChatItems(chat, pagination)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
|
||||
@Binding @ObservedObject var chat: Chat
|
||||
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
|
||||
let index: Int
|
||||
let isLastItem: Bool
|
||||
let chatItem: ChatItem
|
||||
let scrollToItemId: (ChatItem.ID) -> Void
|
||||
let merged: MergedItem
|
||||
let maxWidth: CGFloat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
@Binding var showChatInfoSheet: Bool
|
||||
@Binding var revealedChatItem: ChatItem?
|
||||
@Binding var revealedItems: Set<Int64>
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var showDeleteMessage = false
|
||||
|
@ -943,60 +1068,83 @@ struct ChatView: View {
|
|||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
@State private var markReadTask: Task<Void, Never>? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
var revealed: Bool { revealedItems.contains(chatItem.id) }
|
||||
|
||||
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
|
||||
|
||||
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
|
||||
let im = ItemsModel.shared
|
||||
if let i, i > 0 && im.reversedChatItems.count >= i {
|
||||
let nextItem = im.reversedChatItems[i - 1]
|
||||
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
return (
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
|
||||
)
|
||||
private func reveal(_ yes: Bool) -> Void {
|
||||
merged.revealItems(yes, $revealedItems)
|
||||
}
|
||||
|
||||
func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation {
|
||||
guard let prevItem else {
|
||||
return ItemSeparation(timestamp: true, largeGap: true, date: nil)
|
||||
}
|
||||
|
||||
let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir {
|
||||
groupMember.groupMemberId == prevGroupMember.groupMemberId
|
||||
} else {
|
||||
return (timestamp: true, largeGap: true, date: nil)
|
||||
chatItem.chatDir.sent == prevItem.chatDir.sent
|
||||
}
|
||||
let largeGap = !sameMemberAndDirection || prevItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
|
||||
return ItemSeparation(
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(prevItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: prevItem.meta.itemTs) ? nil : prevItem.meta.itemTs
|
||||
)
|
||||
}
|
||||
|
||||
func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
|
||||
let oldIsGroupRcv = switch older?.chatDir {
|
||||
case .groupRcv: true
|
||||
default: false
|
||||
}
|
||||
let sameMember = switch (older?.chatDir, current.chatDir) {
|
||||
case (.groupRcv(let oldMember), .groupRcv(let member)):
|
||||
oldMember.memberId == member.memberId
|
||||
default:
|
||||
false
|
||||
}
|
||||
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let currIndex = m.getChatItemIndex(chatItem)
|
||||
let ciCategory = chatItem.mergeCategory
|
||||
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
let range = itemsRange(currIndex, prevHidden)
|
||||
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
|
||||
let im = ItemsModel.shared
|
||||
Group {
|
||||
if revealed, let range = range {
|
||||
let items = Array(zip(Array(range), im.reversedChatItems[range]))
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
|
||||
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(ci.id)
|
||||
selectUnselectChatItem(select: !checked, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
let last = isLastItem ? im.reversedChatItems.last : nil
|
||||
let listItem = merged.newest()
|
||||
let item = listItem.item
|
||||
let range: ClosedRange<Int>? = if case let .grouped(_, _, _, rangeInReversed, _, _, _, _) = merged {
|
||||
rangeInReversed.boxedValue
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let showAvatar = shouldShowAvatar(item, listItem.nextItem)
|
||||
let itemSeparation: ItemSeparation
|
||||
let single = switch merged {
|
||||
case .single: true
|
||||
default: false
|
||||
}
|
||||
if single || revealed {
|
||||
let prev = listItem.prevItem
|
||||
itemSeparation = getItemSeparation(item, prev)
|
||||
let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem
|
||||
} else {
|
||||
itemSeparation = getItemSeparation(item, nil)
|
||||
}
|
||||
return VStack(spacing: 0) {
|
||||
if let last {
|
||||
DateSeparator(date: last.meta.itemTs).padding(8)
|
||||
}
|
||||
chatItemListView(range, showAvatar, item, itemSeparation)
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
|
@ -1007,6 +1155,8 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let date = itemSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
@ -1028,6 +1178,10 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
markReadTask?.cancel()
|
||||
markedRead = false
|
||||
}
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
}
|
||||
|
||||
|
@ -1050,10 +1204,14 @@ struct ChatView: View {
|
|||
}
|
||||
|
||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 600_000000)
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
markReadTask = Task {
|
||||
do {
|
||||
_ = try await Task.sleep(nanoseconds: 600_000000)
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
}
|
||||
} catch {
|
||||
// task was cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1103,20 +1261,25 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
|
||||
@ViewBuilder func chatItemListView(
|
||||
_ range: ClosedRange<Int>?,
|
||||
_ showAvatar: Bool,
|
||||
_ ci: ChatItem,
|
||||
_ itemSeparation: ItemSeparation
|
||||
) -> some View {
|
||||
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevMember, memCount): (GroupMember?, Int) =
|
||||
if let range = range {
|
||||
m.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
(nil, 1)
|
||||
}
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
case .group = chat.chatInfo {
|
||||
if showAvatar {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Group {
|
||||
let (prevMember, memCount): (GroupMember?, Int) =
|
||||
if let range = range {
|
||||
m.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
(nil, 1)
|
||||
}
|
||||
if memCount == 1 && member.memberRole > .member {
|
||||
Group {
|
||||
if #available(iOS 16.0, *) {
|
||||
|
@ -1225,6 +1388,7 @@ struct ChatView: View {
|
|||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
scrollToItemId: scrollToItemId,
|
||||
maxWidth: maxWidth,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
|
@ -1660,7 +1824,7 @@ struct ChatView: View {
|
|||
private func hideButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = nil
|
||||
reveal(false)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
|
@ -1741,7 +1905,7 @@ struct ChatView: View {
|
|||
private func revealButton(_ ci: ChatItem) -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = ci
|
||||
reveal(true)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
|
@ -1754,7 +1918,7 @@ struct ChatView: View {
|
|||
private func expandButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = chatItem
|
||||
reveal(true)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
|
@ -1767,7 +1931,7 @@ struct ChatView: View {
|
|||
private func shrinkButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = nil
|
||||
reveal(false)
|
||||
}
|
||||
} label: {
|
||||
Label (
|
||||
|
|
621
apps/ios/Shared/Views/Chat/EndlessScrollView.swift
Normal file
621
apps/ios/Shared/Views/Chat/EndlessScrollView.swift
Normal file
|
@ -0,0 +1,621 @@
|
|||
//
|
||||
// EndlessScrollView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 25.01.2025.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScrollRepresentable<Content: View, ScrollItem>: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
|
||||
|
||||
let scrollView: EndlessScrollView<ScrollItem>
|
||||
let content: (Int, ScrollItem) -> Content
|
||||
|
||||
func makeUIViewController(context: Context) -> ScrollController {
|
||||
ScrollController.init(scrollView: scrollView, content: content)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScrollController, context: Context) {}
|
||||
|
||||
class ScrollController: UIViewController {
|
||||
let scrollView: EndlessScrollView<ScrollItem>
|
||||
fileprivate var items: [ScrollItem] = []
|
||||
fileprivate var content: ((Int, ScrollItem) -> Content)!
|
||||
|
||||
fileprivate init(scrollView: EndlessScrollView<ScrollItem>, content: @escaping (Int, ScrollItem) -> Content) {
|
||||
self.scrollView = scrollView
|
||||
self.content = content
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.view = scrollView
|
||||
scrollView.createCell = createCell
|
||||
scrollView.updateCell = updateCell
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView {
|
||||
let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil
|
||||
let cell: UIView
|
||||
if #available(iOS 16.0, *), false {
|
||||
let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell
|
||||
if let item {
|
||||
c.contentConfiguration = UIHostingConfiguration { self.content(index, item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
}
|
||||
cell = c
|
||||
} else {
|
||||
let c = cellsToReuse.isEmpty ? HostingCell<Content>() : cellsToReuse.removeLast() as! HostingCell<Content>
|
||||
if let item {
|
||||
c.set(content: self.content(index, item), parent: self)
|
||||
}
|
||||
cell = c
|
||||
}
|
||||
cell.isHidden = false
|
||||
cell.backgroundColor = .clear
|
||||
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
|
||||
cell.frame.size.width = scrollView.bounds.width
|
||||
cell.frame.size.height = size.height
|
||||
return cell
|
||||
}
|
||||
|
||||
private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) {
|
||||
let item = items[index]
|
||||
if #available(iOS 16.0, *), false {
|
||||
(cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
cell.set(content: self.content(index, item), parent: self)
|
||||
} else {
|
||||
fatalError("Unexpected Cell Type for: \(item)")
|
||||
}
|
||||
}
|
||||
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
|
||||
cell.frame.size.width = scrollView.bounds.width
|
||||
cell.frame.size.height = size.height
|
||||
cell.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable {
|
||||
|
||||
/// Stores actual state of the scroll view and all elements drawn on the screen
|
||||
let listState: ListState = ListState()
|
||||
|
||||
/// Just some random big number that will probably be enough to scrolling down and up without reaching the end
|
||||
var initialOffset: CGFloat = 100000000
|
||||
|
||||
/// Default item id when no items in the visible items list. Something that will never be in real data
|
||||
fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } }
|
||||
|
||||
/// Storing an offset that was already used for laying down content to be able to see the difference
|
||||
var prevProcessedOffset: CGFloat = 0
|
||||
|
||||
/// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content
|
||||
/// starts from bottom and ends at top, not vice versa as usual
|
||||
var oldScreenHeight: CGFloat = 0
|
||||
|
||||
/// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen
|
||||
var estimatedContentHeight: ContentHeight = ContentHeight()
|
||||
|
||||
/// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too
|
||||
var averageItemHeight: CGFloat = 30
|
||||
|
||||
/// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed
|
||||
var scrollStepMultiplier: CGFloat = 0.6
|
||||
|
||||
/// Adds content padding to top
|
||||
var insetTop: CGFloat = 100
|
||||
|
||||
/// Adds content padding to bottom
|
||||
var insetBottom: CGFloat = 100
|
||||
|
||||
/// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight]
|
||||
let scrollBarView: UIScrollView = UIScrollView(frame: .zero)
|
||||
|
||||
/// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch
|
||||
var cellsToReuse: [UIView] = []
|
||||
|
||||
/// Enable debug to see hundreds of logs
|
||||
var debug: Bool = false
|
||||
|
||||
var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil }
|
||||
var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in }
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.delegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
class ListState: NSObject {
|
||||
|
||||
/// Will be called on every change of the items array, visible items, and scroll position
|
||||
var onUpdateListener: () -> Void = {}
|
||||
|
||||
/// Items that were used to lay out the screen
|
||||
var items: [ScrollItem] = [] {
|
||||
didSet {
|
||||
onUpdateListener()
|
||||
}
|
||||
}
|
||||
|
||||
/// It is equai to the number of [items]
|
||||
var totalItemsCount: Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
/// The items with their positions and other useful information. Only those that are visible on screen
|
||||
var visibleItems: [EndlessScrollView<ScrollItem>.VisibleItem] = []
|
||||
|
||||
/// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen
|
||||
var firstVisibleItemIndex: Int = 0
|
||||
|
||||
/// Unique item id of the first visible item on screen
|
||||
var firstVisibleItemId: any Hashable = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
|
||||
/// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view
|
||||
var firstVisibleItemOffset: CGFloat = -100
|
||||
|
||||
/// Index of the last visible item on screen
|
||||
var lastVisibleItemIndex: Int {
|
||||
visibleItems.last?.index ?? 0
|
||||
}
|
||||
|
||||
/// Whether there is scroll to item in progress or not
|
||||
var isScrolling: Bool = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
class VisibleItem {
|
||||
let index: Int
|
||||
let item: ScrollItem
|
||||
let view: UIView
|
||||
var offset: CGFloat
|
||||
|
||||
init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) {
|
||||
self.index = index
|
||||
self.item = item
|
||||
self.view = view
|
||||
self.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
class ContentHeight {
|
||||
/// After that you should see overscroll effect. When scroll positon is far from
|
||||
/// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example:
|
||||
/// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height
|
||||
var topOffsetY: CGFloat = 0
|
||||
var bottomOffsetY: CGFloat = 0
|
||||
|
||||
var virtualScrollOffsetY: CGFloat = 0
|
||||
|
||||
/// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView
|
||||
var overscrolledTop: CGFloat = 0
|
||||
|
||||
/// Adds content padding to bottom and top
|
||||
var inset: CGFloat = 100
|
||||
|
||||
/// Estimated height of the contents of scroll view
|
||||
var height: CGFloat {
|
||||
get { bottomOffsetY - topOffsetY }
|
||||
}
|
||||
|
||||
/// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar
|
||||
var virtualOverscrolledHeight: CGFloat {
|
||||
get {
|
||||
bottomOffsetY - topOffsetY + overscrolledTop - inset * 2
|
||||
}
|
||||
}
|
||||
|
||||
func update(
|
||||
_ contentOffset: CGPoint,
|
||||
_ listState: ListState,
|
||||
_ averageItemHeight: CGFloat,
|
||||
_ updateStaleHeight: Bool
|
||||
) {
|
||||
let lastVisible = listState.visibleItems.last
|
||||
let firstVisible = listState.visibleItems.first
|
||||
guard let last = lastVisible, let first = firstVisible else {
|
||||
topOffsetY = contentOffset.y
|
||||
bottomOffsetY = contentOffset.y
|
||||
virtualScrollOffsetY = 0
|
||||
overscrolledTop = 0
|
||||
return
|
||||
}
|
||||
topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset
|
||||
bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset
|
||||
virtualScrollOffsetY = contentOffset.y - topOffsetY
|
||||
overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0)
|
||||
}
|
||||
}
|
||||
|
||||
var topY: CGFloat {
|
||||
get { contentOffset.y }
|
||||
}
|
||||
|
||||
var bottomY: CGFloat {
|
||||
get { contentOffset.y + bounds.height }
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if contentSize.height == 0 {
|
||||
setup()
|
||||
}
|
||||
let newScreenHeight = bounds.height
|
||||
if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 {
|
||||
contentOffset.y += oldScreenHeight - newScreenHeight
|
||||
scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom)
|
||||
}
|
||||
oldScreenHeight = newScreenHeight
|
||||
adaptItems(listState.items, false)
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
contentSize = CGSizeMake(frame.size.width, initialOffset * 2)
|
||||
prevProcessedOffset = initialOffset
|
||||
contentOffset = CGPointMake(0, initialOffset)
|
||||
|
||||
showsVerticalScrollIndicator = false
|
||||
scrollBarView.showsHorizontalScrollIndicator = false
|
||||
panGestureRecognizer.delegate = self
|
||||
addGestureRecognizer(scrollBarView.panGestureRecognizer)
|
||||
superview!.addSubview(scrollBarView)
|
||||
}
|
||||
|
||||
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
|
||||
if !Thread.isMainThread {
|
||||
fatalError("Use main thread to update items")
|
||||
}
|
||||
if bounds.height == 0 {
|
||||
self.listState.items = items
|
||||
// this function requires to have valid bounds and it will be called again once it has them
|
||||
return
|
||||
}
|
||||
adaptItems(items, forceReloadVisible)
|
||||
snapToContent()
|
||||
}
|
||||
|
||||
/// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes
|
||||
private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) {
|
||||
let start = Date.now
|
||||
// special case when everything was removed
|
||||
if items.isEmpty {
|
||||
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
|
||||
listState.visibleItems = []
|
||||
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = 0
|
||||
listState.firstVisibleItemOffset = -insetTop
|
||||
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
scrollBarView.contentSize = .zero
|
||||
scrollBarView.contentOffset = .zero
|
||||
|
||||
prevProcessedOffset = contentOffset.y
|
||||
// this check is just to prevent didSet listener from firing on the same empty array, no use for this
|
||||
if !self.listState.items.isEmpty {
|
||||
self.listState.items = items
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let contentOffsetY = overridenOffset ?? contentOffset.y
|
||||
|
||||
var oldVisible = listState.visibleItems
|
||||
var newVisible: [VisibleItem] = []
|
||||
let offsetsDiff = contentOffsetY - prevProcessedOffset
|
||||
|
||||
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
|
||||
|
||||
var allowOneMore = false
|
||||
var nextOffsetY: CGFloat = 0
|
||||
var i = shouldBeFirstVisible
|
||||
// building list of visible items starting from the first one that should be visible
|
||||
while i >= 0 && i < items.count {
|
||||
let item = items[i]
|
||||
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
|
||||
let visible: VisibleItem?
|
||||
if let visibleIndex {
|
||||
let v = oldVisible.remove(at: visibleIndex)
|
||||
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
|
||||
updateCell(v.view, i, items)
|
||||
}
|
||||
visible = v
|
||||
} else {
|
||||
visible = nil
|
||||
}
|
||||
if shouldBeFirstVisible == i {
|
||||
if let vis = visible {
|
||||
let oldHeight = vis.view.frame.height
|
||||
vis.view.frame.origin.y += oldHeight - vis.view.frame.height
|
||||
// the fist visible item previously is hidden now, remove it and move on
|
||||
if !isVisible(vis.view) {
|
||||
let newIndex: Int
|
||||
if listState.isScrolling {
|
||||
// skip many items to make the scrolling take less time
|
||||
var indexDiff = Int(ceil(abs(offsetsDiff / averageItemHeight)))
|
||||
indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff
|
||||
newIndex = max(0, min(items.count - 1, i + indexDiff))
|
||||
} else {
|
||||
// don't skip multiple items if it's manual scrolling gesture
|
||||
newIndex = i + (offsetsDiff <= 0 ? 1 : -1)
|
||||
}
|
||||
shouldBeFirstVisible = newIndex
|
||||
i = newIndex
|
||||
|
||||
cellsToReuse.append(vis.view)
|
||||
hideAndRemoveFromSuperviewIfNeeded(vis.view)
|
||||
continue
|
||||
}
|
||||
}
|
||||
let vis: VisibleItem
|
||||
if let visible {
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
cell.frame.origin.y = bottomY + listState.firstVisibleItemOffset - cell.frame.height
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
nextOffsetY = vis.view.frame.origin.y
|
||||
} else {
|
||||
let vis: VisibleItem
|
||||
if let visible {
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
nextOffsetY -= vis.view.frame.height
|
||||
vis.view.frame.origin.y = nextOffsetY
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
nextOffsetY -= cell.frame.height
|
||||
cell.frame.origin.y = nextOffsetY
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
}
|
||||
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
|
||||
break
|
||||
} else if abs(nextOffsetY) < contentOffsetY {
|
||||
allowOneMore = false
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 {
|
||||
var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height
|
||||
let index = firstVisible.index
|
||||
for i in stride(from: index - 1, through: 0, by: -1) {
|
||||
let item = items[i]
|
||||
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
|
||||
let vis: VisibleItem
|
||||
if let visibleIndex {
|
||||
let visible = oldVisible.remove(at: visibleIndex)
|
||||
visible.view.frame.origin.y = offset
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
cell.frame.origin.y = offset
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
offset += vis.view.frame.height
|
||||
newVisible.insert(vis, at: 0)
|
||||
if offset >= contentOffsetY + bounds.height {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removing already unneeded visible items
|
||||
oldVisible.forEach { vis in
|
||||
cellsToReuse.append(vis.view)
|
||||
hideAndRemoveFromSuperviewIfNeeded(vis.view)
|
||||
}
|
||||
let itemsCountChanged = listState.items.count != items.count
|
||||
prevProcessedOffset = contentOffsetY
|
||||
|
||||
listState.visibleItems = newVisible
|
||||
listState.items = items
|
||||
|
||||
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
|
||||
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
|
||||
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
|
||||
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
|
||||
scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY)
|
||||
scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height)
|
||||
|
||||
if debug {
|
||||
println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))")
|
||||
}
|
||||
}
|
||||
|
||||
func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) {
|
||||
listState.firstVisibleItemIndex = index
|
||||
listState.firstVisibleItemId = id
|
||||
listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset
|
||||
}
|
||||
|
||||
func scrollToItem(_ index: Int, animated: Bool, top: Bool = true) async {
|
||||
if index >= listState.items.count || listState.isScrolling {
|
||||
return
|
||||
}
|
||||
listState.isScrolling = true
|
||||
defer {
|
||||
listState.isScrolling = false
|
||||
}
|
||||
if !animated {
|
||||
// just a faster way to set top item as requested index
|
||||
listState.firstVisibleItemIndex = index
|
||||
listState.firstVisibleItemId = listState.items[index].id
|
||||
listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom
|
||||
scrollBarView.flashScrollIndicators()
|
||||
adaptItems(listState.items, false)
|
||||
}
|
||||
var adjustedOffset = self.contentOffset.y
|
||||
var i = 0
|
||||
|
||||
var upPrev = index > listState.firstVisibleItemIndex
|
||||
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||
|
||||
var stepSlowdownMultiplier: CGFloat = 1
|
||||
while true {
|
||||
let up = index > listState.firstVisibleItemIndex
|
||||
if upPrev != up {
|
||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||
upPrev = up
|
||||
}
|
||||
|
||||
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
|
||||
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
|
||||
|
||||
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier
|
||||
adjustedOffset += offsetToScroll
|
||||
if let item = listState.visibleItems.first(where: { $0.index == index }) {
|
||||
let y = if top {
|
||||
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
|
||||
} else {
|
||||
max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
|
||||
}
|
||||
setContentOffset(CGPointMake(contentOffset.x, y), animated: animated)
|
||||
scrollBarView.flashScrollIndicators()
|
||||
break
|
||||
}
|
||||
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
||||
if animated {
|
||||
// skipping unneded relayout if this offset is already processed
|
||||
if prevProcessedOffset - contentOffset.y != 0 {
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
}
|
||||
// let UI time to update to see the animated position change
|
||||
await MainActor.run {}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
}
|
||||
|
||||
func scrollToBottom(animated: Bool = true) {
|
||||
Task {
|
||||
await scrollToItem(0, animated: animated, top: false)
|
||||
}
|
||||
}
|
||||
|
||||
func scroll(by: CGFloat, animated: Bool = true) {
|
||||
setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated)
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
if !listState.items.isEmpty {
|
||||
scrollToBottom()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func snapToContent(animated: Bool = true) {
|
||||
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
|
||||
if topY < estimatedContentHeight.topOffsetY - topBlankSpace {
|
||||
setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated)
|
||||
} else if bottomY > estimatedContentHeight.bottomOffsetY {
|
||||
setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
func offsetToBottom(_ view: UIView) -> CGFloat {
|
||||
bottomY - (view.frame.origin.y + view.frame.height)
|
||||
}
|
||||
|
||||
/// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of
|
||||
/// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view
|
||||
func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) {
|
||||
if view.isHidden {
|
||||
// already passed this function
|
||||
return
|
||||
}
|
||||
(view as? ReusableView)?.prepareForReuse()
|
||||
view.isHidden = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
if view.isHidden { view.removeFromSuperview() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizing both scrollViews
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate {
|
||||
snapToContent()
|
||||
}
|
||||
}
|
||||
|
||||
override var contentOffset: CGPoint {
|
||||
get { super.contentOffset }
|
||||
set {
|
||||
var newOffset = newValue
|
||||
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
|
||||
if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y {
|
||||
if !isDecelerating {
|
||||
newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setContentOffset(newValue, animated: false)
|
||||
self.snapToContent()
|
||||
}
|
||||
}
|
||||
} else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y {
|
||||
if !isDecelerating {
|
||||
newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setContentOffset(newValue, animated: false)
|
||||
self.snapToContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.contentOffset = newOffset
|
||||
}
|
||||
}
|
||||
|
||||
private func stopScrolling() {
|
||||
let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) {
|
||||
estimatedContentHeight.topOffsetY
|
||||
} else {
|
||||
estimatedContentHeight.bottomOffsetY - bounds.height
|
||||
}
|
||||
setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false)
|
||||
}
|
||||
|
||||
func isVisible(_ view: UIView) -> Bool {
|
||||
if view.superview == nil {
|
||||
return false
|
||||
}
|
||||
return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height))
|
||||
}
|
||||
}
|
||||
|
||||
private func println(_ text: String) {
|
||||
print("\(Date.now.timeIntervalSince1970): \(text)")
|
||||
}
|
|
@ -366,14 +366,8 @@ struct GroupMemberInfoView: View {
|
|||
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -398,7 +392,7 @@ struct GroupMemberInfoView: View {
|
|||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
ItemsModel.shared.loadOpenChat("@\(memberContact.id)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
|
|
|
@ -1,371 +0,0 @@
|
|||
//
|
||||
// ReverseList.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 11/06/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
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>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (ChatItem) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(representer: self)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
controller.representer = self
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
controller.view.layer.removeAllAnimations()
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
case .bottom:
|
||||
controller.scroll(to: 0, position: .top)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
self.representer = representer
|
||||
super.init(style: .plain)
|
||||
|
||||
// 1. Style
|
||||
tableView = InvertedTableView()
|
||||
tableView.separatorStyle = .none
|
||||
tableView.transform = .verticalFlip
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
// 2. Register cells
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.register(
|
||||
UITableViewCell.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
} else {
|
||||
tableView.register(
|
||||
HostingCell<Content>.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
self.representer.loadPage()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
cell.set(content: self.representer.content(item), parent: self)
|
||||
} else {
|
||||
fatalError("Unexpected Cell Type for: \(item)")
|
||||
}
|
||||
}
|
||||
cell.transform = .verticalFlip
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
// 4. External state changes will require manual layout updates
|
||||
NotificationCenter.default
|
||||
.addObserver(
|
||||
self,
|
||||
selector: #selector(updateLayout),
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
|
||||
updateFloatingButtons
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
|
||||
.sink {
|
||||
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
|
||||
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
|
||||
}
|
||||
}
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
@objc private func updateLayout() {
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.setNeedsLayout()
|
||||
tableView.layoutIfNeeded()
|
||||
} else {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides keyboard, when user begins to scroll.
|
||||
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
UIApplication.shared
|
||||
.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
tableView.clipsToBounds = false
|
||||
parent?.viewIfLoaded?.clipsToBounds = false
|
||||
}
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(
|
||||
x: tableView.contentOffset.x,
|
||||
y: tableView.contentOffset.y + tableView.bounds.height
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
} else {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
)
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: 0, y: -InvertedTableView.inset),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
func getListState() -> ListState? {
|
||||
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
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isVisible(indexPath: IndexPath) -> Bool {
|
||||
if let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) {
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||
if !contentView.subviews.contains(hostingController.view) {
|
||||
contentView.addSubview(hostingController.view)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingView.leadingAnchor
|
||||
.constraint(equalTo: contentView.leadingAnchor),
|
||||
hostingView.trailingAnchor
|
||||
.constraint(equalTo: contentView.trailingAnchor),
|
||||
hostingView.topAnchor
|
||||
.constraint(equalTo: contentView.topAnchor),
|
||||
hostingView.bottomAnchor
|
||||
.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||
} else {
|
||||
fatalError("Hosting View not loaded \(hostingController)")
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
hostingController.rootView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias ListState = (
|
||||
scrollOffset: Double,
|
||||
topItemDate: Date?,
|
||||
bottomItemId: ChatItem.ID?
|
||||
)
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel: ObservableObject {
|
||||
/// Represents Scroll State of ``ReverseList``
|
||||
enum State: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case nextPage
|
||||
case item(ChatItem.ID)
|
||||
case bottom
|
||||
}
|
||||
|
||||
case scrollingTo(Destination)
|
||||
case atDestination
|
||||
}
|
||||
|
||||
@Published var state: State = .atDestination
|
||||
|
||||
func scrollToNextPage() {
|
||||
state = .scrollingTo(.nextPage)
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
state = .scrollingTo(.bottom)
|
||||
}
|
||||
|
||||
func scrollToItem(id: ChatItem.ID) {
|
||||
state = .scrollingTo(.item(id))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let cellReuseId = "hostingCell"
|
||||
|
||||
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||
|
||||
fileprivate extension CGAffineTransform {
|
||||
/// Transform that vertically flips the view, preserving it's location
|
||||
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||
}
|
||||
|
||||
extension NotificationCenter {
|
||||
static func postReverseListNeedsLayout() {
|
||||
NotificationCenter.default.post(
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable animation on iOS 15
|
||||
func withConditionalAnimation<Result>(
|
||||
_ animation: Animation? = .default,
|
||||
_ body: () throws -> Result
|
||||
) rethrows -> Result {
|
||||
if #available(iOS 16.0, *) {
|
||||
try withAnimation(animation, body)
|
||||
} else {
|
||||
try body()
|
||||
}
|
||||
}
|
||||
|
||||
class InvertedTableView: UITableView {
|
||||
static let inset = CGFloat(100)
|
||||
|
||||
static let insets = UIEdgeInsets(
|
||||
top: inset,
|
||||
left: .zero,
|
||||
bottom: inset,
|
||||
right: .zero
|
||||
)
|
||||
|
||||
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
|
||||
get { .never }
|
||||
set { }
|
||||
}
|
||||
|
||||
override var contentInset: UIEdgeInsets {
|
||||
get { Self.insets }
|
||||
set { }
|
||||
}
|
||||
|
||||
override var adjustedContentInset: UIEdgeInsets {
|
||||
Self.insets
|
||||
}
|
||||
}
|
52
apps/ios/Shared/Views/Chat/ScrollViewCells.swift
Normal file
52
apps/ios/Shared/Views/Chat/ScrollViewCells.swift
Normal file
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// ScrollViewCells.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 27.01.2025.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
protocol ReusableView {
|
||||
func prepareForReuse()
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController`
|
||||
final class HostingCell<Hosted: View>: UIView, ReusableView {
|
||||
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||
if !subviews.contains(hostingController.view) {
|
||||
addSubview(hostingController.view)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingView.leadingAnchor
|
||||
.constraint(equalTo: leadingAnchor),
|
||||
hostingView.trailingAnchor
|
||||
.constraint(equalTo: trailingAnchor),
|
||||
hostingView.topAnchor
|
||||
.constraint(equalTo: topAnchor),
|
||||
hostingView.bottomAnchor
|
||||
.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||
} else {
|
||||
fatalError("Hosting View not loaded \(hostingController)")
|
||||
}
|
||||
}
|
||||
|
||||
func prepareForReuse() {
|
||||
//super.prepareForReuse()
|
||||
hostingController.rootView = nil
|
||||
}
|
||||
}
|
|
@ -384,12 +384,10 @@ struct ChatPreviewView: View {
|
|||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
|
|
|
@ -65,6 +65,7 @@ struct LocalAuthView: View {
|
|||
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
||||
m.chatId = nil
|
||||
ItemsModel.shared.reversedChatItems = []
|
||||
ItemsModel.shared.chatItemsChangesListener.cleared()
|
||||
m.updateChats([])
|
||||
m.users = []
|
||||
_ = kcAppPassword.set(password)
|
||||
|
|
|
@ -367,13 +367,13 @@ struct ChatThemePreview: View {
|
|||
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
|
||||
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
|
||||
HStack {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
|
||||
.modifier(ChatItemClipped(alice, tailVisible: true))
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
|
||||
.modifier(ChatItemClipped(bob, tailVisible: true))
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
|
|
|
@ -174,8 +174,8 @@
|
|||
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
|
||||
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
|
||||
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */; };
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */; };
|
||||
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
|
||||
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
|
||||
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
|
||||
|
@ -198,9 +198,14 @@
|
|||
8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; };
|
||||
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; };
|
||||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; };
|
||||
8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */; };
|
||||
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; };
|
||||
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; };
|
||||
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; };
|
||||
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; };
|
||||
8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; };
|
||||
8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */; };
|
||||
8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */; };
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
|
@ -225,7 +230,6 @@
|
|||
CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; };
|
||||
CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; };
|
||||
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
|
||||
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; };
|
||||
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
|
@ -527,8 +531,8 @@
|
|||
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a"; sourceTree = "<group>"; };
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a"; sourceTree = "<group>"; };
|
||||
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
|
||||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
|
||||
|
@ -551,8 +555,13 @@
|
|||
8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
||||
8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = "<group>"; };
|
||||
8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollHelpers.swift; sourceTree = "<group>"; };
|
||||
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = "<group>"; };
|
||||
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = "<group>"; };
|
||||
8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = "<group>"; };
|
||||
8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = "<group>"; };
|
||||
8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndlessScrollView.swift; sourceTree = "<group>"; };
|
||||
8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewCells.swift; sourceTree = "<group>"; };
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
|
@ -576,7 +585,6 @@
|
|||
CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX SE.entitlements"; sourceTree = "<group>"; };
|
||||
CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = "<group>"; };
|
||||
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
|
||||
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = "<group>"; };
|
||||
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
|
||||
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
|
@ -680,8 +688,8 @@
|
|||
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
|
||||
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
|
||||
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */,
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -737,6 +745,8 @@
|
|||
5C5F4AC227A5E9AF00B51EF1 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */,
|
||||
8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */,
|
||||
6440CA01288AEC770062C672 /* Group */,
|
||||
5CE4407427ADB657007B033A /* ChatItem */,
|
||||
5CEACCE527DE977C000BD591 /* ComposeMessage */,
|
||||
|
@ -747,11 +757,13 @@
|
|||
5CE4407127ADB1D0007B033A /* Emoji.swift */,
|
||||
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
|
||||
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
|
||||
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */,
|
||||
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
|
||||
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */,
|
||||
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */,
|
||||
8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
|
@ -762,8 +774,8 @@
|
|||
64C829992D54AEEE006B9E89 /* libffi.a */,
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */,
|
||||
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1408,8 +1420,8 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */,
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
|
||||
8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */,
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
|
||||
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
|
||||
|
@ -1427,6 +1439,7 @@
|
|||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
|
||||
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
|
||||
8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */,
|
||||
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
|
||||
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
|
||||
8C74C3EC2C1B92A900039E77 /* Theme.swift in Sources */,
|
||||
|
@ -1516,6 +1529,7 @@
|
|||
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */,
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */,
|
||||
8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */,
|
||||
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */,
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */,
|
||||
|
@ -1527,6 +1541,7 @@
|
|||
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
|
||||
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
|
||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
|
||||
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */,
|
||||
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
|
||||
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */,
|
||||
|
@ -1566,6 +1581,7 @@
|
|||
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */,
|
||||
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
|
||||
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */,
|
||||
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
|
||||
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
|
||||
|
@ -1947,7 +1963,7 @@
|
|||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -1996,7 +2012,7 @@
|
|||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -2037,7 +2053,7 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2057,7 +2073,7 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2082,7 +2098,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
|
@ -2119,7 +2135,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
|
@ -2156,7 +2172,7 @@
|
|||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -2207,7 +2223,7 @@
|
|||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -2258,7 +2274,7 @@
|
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
@ -2292,7 +2308,7 @@
|
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 263;
|
||||
CURRENT_PROJECT_VERSION = 265;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
|
|
@ -203,7 +203,7 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
|||
let jChats = jApiChats["chats"] as? NSArray {
|
||||
let chats = jChats.map { jChat in
|
||||
if let chatData = try? parseChatData(jChat) {
|
||||
return chatData
|
||||
return chatData.0
|
||||
}
|
||||
return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "")
|
||||
}
|
||||
|
@ -213,8 +213,8 @@ 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 (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) {
|
||||
return .apiChat(user: user, chat: chat, navInfo: navInfo)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
|
@ -247,10 +247,11 @@ private func errorJson(_ jDict: NSDictionary) -> String? {
|
|||
}
|
||||
}
|
||||
|
||||
func parseChatData(_ jChat: Any) throws -> ChatData {
|
||||
func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
|
||||
let jChatDict = jChat as! NSDictionary
|
||||
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
|
||||
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
|
||||
let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!)
|
||||
let jChatItems = jChatDict["chatItems"] as! NSArray
|
||||
let chatItems = jChatItems.map { jCI in
|
||||
if let ci: ChatItem = try? decodeObject(jCI) {
|
||||
|
@ -262,7 +263,7 @@ func parseChatData(_ jChat: Any) throws -> ChatData {
|
|||
json: serializeJSON(jCI, options: .prettyPrinted) ?? ""
|
||||
)
|
||||
}
|
||||
return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats)
|
||||
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
|
||||
}
|
||||
|
||||
func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
|
||||
|
|
|
@ -42,7 +42,7 @@ public enum ChatCommand {
|
|||
case apiGetSettings(settings: AppSettings)
|
||||
case apiGetChatTags(userId: Int64)
|
||||
case apiGetChats(userId: Int64)
|
||||
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
|
||||
case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String)
|
||||
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
|
||||
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
|
||||
case apiCreateChatTag(tag: ChatTagData)
|
||||
|
@ -212,7 +212,7 @@ public enum ChatCommand {
|
|||
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
|
||||
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
|
||||
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
|
||||
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
|
||||
case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" +
|
||||
(search == "" ? "" : " search=\(search)")
|
||||
case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
|
||||
case let .apiSendMessages(type, id, live, ttl, composedMessages):
|
||||
|
@ -600,7 +600,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, navInfo: NavigationInfo?)
|
||||
case chatTags(user: UserRef, userTags: [ChatTag])
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
|
@ -958,7 +958,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, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))")
|
||||
case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
|
@ -1209,10 +1209,15 @@ struct NewUser: Encodable {
|
|||
}
|
||||
|
||||
public enum ChatPagination {
|
||||
public static let INITIAL_COUNT = 75
|
||||
public static let PRELOAD_COUNT = 100
|
||||
public static let UNTIL_PRELOAD_COUNT = 50
|
||||
|
||||
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 {
|
||||
|
@ -1220,6 +1225,7 @@ public enum ChatPagination {
|
|||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1227,7 +1233,7 @@ public enum ChatPagination {
|
|||
public struct ChatTagData: Encodable {
|
||||
public var emoji: String?
|
||||
public var text: String
|
||||
|
||||
|
||||
public init(emoji: String?, text: String) {
|
||||
self.emoji = emoji
|
||||
self.text = text
|
||||
|
@ -1483,7 +1489,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable {
|
|||
}
|
||||
set { `operator` = newValue }
|
||||
}
|
||||
|
||||
|
||||
public static var sampleData1 = UserOperatorServers(
|
||||
operator: ServerOperator.sampleData1,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
|
@ -1794,7 +1800,7 @@ public struct NetCfg: Codable, Equatable {
|
|||
rcvConcurrency: 8,
|
||||
smpPingInterval: 1200_000_000
|
||||
)
|
||||
|
||||
|
||||
public var withProxyTimeouts: NetCfg {
|
||||
var cfg = self
|
||||
cfg.tcpConnectTimeout = NetCfg.proxyDefaults.tcpConnectTimeout
|
||||
|
@ -1804,7 +1810,7 @@ public struct NetCfg: Codable, Equatable {
|
|||
cfg.smpPingInterval = NetCfg.proxyDefaults.smpPingInterval
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
public var hasProxyTimeouts: Bool {
|
||||
tcpConnectTimeout == NetCfg.proxyDefaults.tcpConnectTimeout &&
|
||||
tcpTimeout == NetCfg.proxyDefaults.tcpTimeout &&
|
||||
|
@ -1937,7 +1943,7 @@ public struct NetworkProxy: Equatable, Codable {
|
|||
public static var def: NetworkProxy {
|
||||
NetworkProxy()
|
||||
}
|
||||
|
||||
|
||||
public var valid: Bool {
|
||||
let hostOk = switch NWEndpoint.Host(host) {
|
||||
case .ipv4: true
|
||||
|
@ -1948,7 +1954,7 @@ public struct NetworkProxy: Equatable, Codable {
|
|||
port > 0 && port <= 65535 &&
|
||||
NetworkProxy.validCredential(username) && NetworkProxy.validCredential(password)
|
||||
}
|
||||
|
||||
|
||||
public static func validCredential(_ s: String) -> Bool {
|
||||
!s.contains(":") && !s.contains("@")
|
||||
}
|
||||
|
@ -2048,6 +2054,16 @@ public struct ChatSettings: Codable, Hashable {
|
|||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
|
||||
}
|
||||
|
||||
public struct NavigationInfo: Decodable {
|
||||
public var afterUnread: Int = 0
|
||||
public var afterTotal: Int = 0
|
||||
|
||||
public init(afterUnread: Int = 0, afterTotal: Int = 0) {
|
||||
self.afterUnread = afterUnread
|
||||
self.afterTotal = afterTotal
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgFilter: String, Codable, Hashable {
|
||||
case none
|
||||
case all
|
||||
|
@ -2254,7 +2270,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
|
|||
case .instant: "Instant"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var icon: String {
|
||||
switch self {
|
||||
case .off: return "arrow.clockwise"
|
||||
|
@ -2769,7 +2785,7 @@ public struct AppSettings: Codable, Equatable {
|
|||
public var uiThemes: [ThemeOverrides]? = nil
|
||||
public var oneHandUI: Bool? = nil
|
||||
public var chatBottomBar: Bool? = nil
|
||||
|
||||
|
||||
public func prepareForExport() -> AppSettings {
|
||||
var empty = AppSettings()
|
||||
let def = AppSettings.defaults
|
||||
|
|
|
@ -3523,8 +3523,12 @@ extension MsgReaction: Decodable {
|
|||
let type = try container.decode(String.self, forKey: CodingKeys.type)
|
||||
switch type {
|
||||
case "emoji":
|
||||
let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji)
|
||||
self = .emoji(emoji: emoji)
|
||||
do {
|
||||
let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji)
|
||||
self = .emoji(emoji: emoji)
|
||||
} catch {
|
||||
self = .unknown(type: "emoji")
|
||||
}
|
||||
default:
|
||||
self = .unknown(type: type)
|
||||
}
|
||||
|
|
|
@ -3311,7 +3311,7 @@ sealed class MsgReaction {
|
|||
MREmojiChar.Heart -> "❤️"
|
||||
else -> emoji.value
|
||||
}
|
||||
is Unknown -> ""
|
||||
is Unknown -> "?"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -3341,8 +3341,13 @@ object MsgReactionSerializer : KSerializer<MsgReaction> {
|
|||
return if (json is JsonObject && "type" in json) {
|
||||
when(val t = json["type"]?.jsonPrimitive?.content ?: "") {
|
||||
"emoji" -> {
|
||||
val emoji = Json.decodeFromString<MREmojiChar>(json["emoji"].toString())
|
||||
if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji)
|
||||
val msgReaction = try {
|
||||
val emoji = Json.decodeFromString<MREmojiChar>(json["emoji"].toString())
|
||||
MsgReaction.Emoji(emoji)
|
||||
} catch (e: Throwable) {
|
||||
MsgReaction.Unknown(t, json)
|
||||
}
|
||||
msgReaction
|
||||
}
|
||||
else -> MsgReaction.Unknown(t, json)
|
||||
}
|
||||
|
|
|
@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
|
|||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
kotlin.jvm.target=11
|
||||
|
||||
android.version_name=6.3-beta.4
|
||||
android.version_code=273
|
||||
android.version_name=6.3-beta.5
|
||||
android.version_code=276
|
||||
|
||||
desktop.version_name=6.3-beta.4
|
||||
desktop.version_code=91
|
||||
desktop.version_name=6.3-beta.5
|
||||
desktop.version_code=93
|
||||
|
||||
kotlin.version=1.9.23
|
||||
gradle.plugin.version=8.2.0
|
||||
|
|
|
@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
|||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: bd97cb04495b90412c1300fd1a4862f488db85cb
|
||||
tag: c192339af9e9342902731f2d49ff380359be0dec
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
|
97
docs/rfcs/2025-02-17-member-send-limits.md
Normal file
97
docs/rfcs/2025-02-17-member-send-limits.md
Normal file
|
@ -0,0 +1,97 @@
|
|||
# Member sending limits
|
||||
|
||||
## Problem
|
||||
|
||||
Rate limiting member sending to prevent abuse in groups.
|
||||
|
||||
## Solution
|
||||
|
||||
Each member record to have `rateLimit :: Maybe MemberRateLimit`, Nothing by default. Nothing means default rate limit for regular members (`MRLWindow`), and no limit for owners/admins/moderators (`MRLNone`). Default rate limit is defined in configuration, e.g. limit of 15 messages in 60 second window, or could be defined in group profile.
|
||||
|
||||
Rate limit can be overridden per member by sending `XGrpMemRestrict` with updated `rateLimit` by member of same role or higher, similar to changing roles. `APIRateLimitMember` allows to enable or disable rate limiting for member, we could also provide more granular control in it (pass `WindowLimit`), but it seems unnecessary complex for UI.
|
||||
|
||||
```haskell
|
||||
data MemberRateLimit
|
||||
= MRLNone -- default for owners, admins, moderators
|
||||
| MRLWindow WindowLimit
|
||||
deriving (Eq, Show)
|
||||
|
||||
data WindowLimit = WindowLimit
|
||||
{ window :: Int, -- seconds
|
||||
limit :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- sent in XGrpMemRestrict
|
||||
data MemberRestrictions = MemberRestrictions
|
||||
{ restriction :: MemberRestrictionStatus,
|
||||
rateLimit :: Maybe MemberRateLimit -- Nothing means use default
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- new api in ChatCommand
|
||||
| APIRateLimitMember GroupId GroupMemberId
|
||||
|
||||
-- new response in ChatResponse
|
||||
| CRMemberRateLimit {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
|
||||
-- new field in GroupMember
|
||||
data GroupMember = GroupMember {
|
||||
...
|
||||
rateLimit :: Maybe MemberRateLimit,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Rate limit overrides to be persisted on group member records.
|
||||
|
||||
```sql
|
||||
ALTER TABLE group_members ADD COLUMN rate_limit TEXT; -- MemberRateLimit JSON encoded
|
||||
```
|
||||
|
||||
Limits can be tracked inside fixed windows both for receiving and sending.
|
||||
|
||||
```haskell
|
||||
data ChatController = ChatController {
|
||||
...
|
||||
memberLimits :: TMap (GroupId, MemberId) (TVar MemberRateLimitWindow),
|
||||
ownLimits :: TMap GroupId (TVar MemberRateLimitWindow),
|
||||
...
|
||||
}
|
||||
|
||||
data MemberRateLimitWindow = MemberRateLimitWindow {
|
||||
startedAt :: UTCTime,
|
||||
windowLimit :: WindowLimit,
|
||||
messages :: Int
|
||||
}
|
||||
```
|
||||
|
||||
Client to track limit for each writing member in state - `memberLimits`. If current window's interval has passed, checked against `startedAt` of `MemberRateLimitWindow`, reset `messages` counter.
|
||||
|
||||
Track own limits per group - `ownLimits`. When limit in group is reached, `CRGroupSendingLimited blocked = True` event is sent to UI to block sending in group. Unblock group sending in UI by scheduling background process to send `CRGroupSendingLimited blocked = False` after interval?
|
||||
|
||||
```haskell
|
||||
-- new event in ChatResponse
|
||||
| CRGroupSendingLimited {user :: User, groupInfo :: GroupInfo, blocked :: Bool}
|
||||
```
|
||||
|
||||
### Receiving messages from limited members
|
||||
|
||||
Receiving message from member that exceeded their limit would fail it as prohibited. We can limit content messages, updates, reactions, etc. Practically only regular members would be limited, so there's no need for limiting service messages. Should we limit deletes?
|
||||
|
||||
Problems:
|
||||
|
||||
- Inconsistencies in window tracking on sending and receiving sides -> track based on item_ts?;
|
||||
- Subscription produces message surges;
|
||||
- Server downtime (or network outage) leads to accumulation of scheduled messages on sending side -> item_ts tracking wouldn't help. Issue is similar to subscription, where many messages can be received in short span legitimately.
|
||||
- This approach doesn't reduce load of retrieving message and of all machinery passing from agent to chat.
|
||||
|
||||
Subscription issue could be solved by not tracking limits during subscription (client "knows" when it has subscribed) and for some time after it. For how long - 30 seconds / 1 minute? - arbitrary, longer absence periods lead to more not yet retrieved messages, freed quotas resulting in more sent pending messages.
|
||||
|
||||
Better solution would be to not drop (prohibit) messages at all, but stop reception per connection for periods of time. Possible approaches:
|
||||
|
||||
- Don't send (delay) ACK - bad idea as it would lead to repeated processing on client restart, and other possible failures in delivery.
|
||||
- ACK with parameter `wait` for server - server would wait before sending next message.
|
||||
- Unsubscribe (kill client?)/resubscribe after some time - more expensive.
|
||||
- Signal agent to hold on next message - similar to delayed ACK but at least we don't process message. (for example, also via ACK but parameter to wait is for agent)
|
||||
- Rework chat-to-agent communication to communicate via per connection queue (currently it's single `subQ`) - complex - but we get less expensive "unsubscribe"? Essentially agent still holds on to message like in previous approach.
|
|
@ -38,6 +38,27 @@
|
|||
</description>
|
||||
|
||||
<releases>
|
||||
<release version="6.2.5" date="2025-02-16">
|
||||
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
|
||||
<description>
|
||||
<p>New in v6.2.1-5:</p>
|
||||
<ul>
|
||||
<li>change media filenames when forwarding.</li>
|
||||
<li>fully delete wallpapers when deleting user or chat.</li>
|
||||
<li>important fixes</li>
|
||||
<li>offer to "fix" encryption when calling or making direct connection with member.</li>
|
||||
<li>broken layout.</li>
|
||||
<li>option to enable debug logs (disabled by default).</li>
|
||||
<li>show who reacted in direct chats.</li>
|
||||
</ul>
|
||||
<p>New in v6.2:</p>
|
||||
<ul>
|
||||
<li>SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.</li>
|
||||
<li>Business chats – your customers privacy.</li>
|
||||
<li>Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="6.2.4" date="2025-01-14">
|
||||
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
|
||||
<description>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."bd97cb04495b90412c1300fd1a4862f488db85cb" = "19i0r2b4kfkq2zlbmq134a0hk0vszhm6wdlfyp58d35zqrc0xadf";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."c192339af9e9342902731f2d49ff380359be0dec" = "1h81kmwllqza7wil7w20ia934b6iny3dch9fln0x95l9q3f4zww7";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
|
|
@ -5,7 +5,7 @@ cabal-version: 1.12
|
|||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 6.3.0.4
|
||||
version: 6.3.0.5
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
|
|
@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared
|
|||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Util (liftIOEither)
|
||||
import Simplex.Chat.Util (liftIOEither, zipWith3')
|
||||
import qualified Simplex.Chat.Util as U
|
||||
import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard)
|
||||
import Simplex.Messaging.Agent as Agent
|
||||
|
@ -1893,6 +1893,12 @@ processChatCommand' vr = \case
|
|||
pure CRBroadcastSent {user, msgContent = mc, successes = 0, failures = 0, timestamp}
|
||||
Just (ctConns :: NonEmpty (Contact, Connection)) -> do
|
||||
let idsEvts = L.map ctSndEvent ctConns
|
||||
-- TODO Broadcast rework
|
||||
-- In createNewSndMessage and encodeChatMessage we could use Nothing for sharedMsgId,
|
||||
-- then we could reuse message body across broadcast.
|
||||
-- Encoding different sharedMsgId and reusing body is meaningless as referencing will not work anyway.
|
||||
-- As an improvement, single message record with its sharedMsgId could be created for new "broadcast" entity.
|
||||
-- Then all recipients could refer to broadcast message using same sharedMsgId.
|
||||
sndMsgs <- lift $ createSndMessages idsEvts
|
||||
let msgReqs_ :: NonEmpty (Either ChatError ChatMsgReq) = L.zipWith (fmap . ctMsgReq) ctConns sndMsgs
|
||||
(errs, ctSndMsgs :: [(Contact, SndMessage)]) <-
|
||||
|
@ -1909,9 +1915,7 @@ processChatCommand' vr = \case
|
|||
ctSndEvent :: (Contact, Connection) -> (ConnOrGroupId, ChatMsgEvent 'Json)
|
||||
ctSndEvent (_, Connection {connId}) = (ConnectionId connId, XMsgNew $ MCSimple (extMsgContent mc Nothing))
|
||||
ctMsgReq :: (Contact, Connection) -> SndMessage -> ChatMsgReq
|
||||
ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, msgBody, [msgId])
|
||||
zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d
|
||||
zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs
|
||||
ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, (vrValue msgBody, [msgId]))
|
||||
combineResults :: (Contact, Connection) -> Either ChatError SndMessage -> Either ChatError ([Int64], PQEncryption) -> Either ChatError (Contact, SndMessage)
|
||||
combineResults (ct, _) (Right msg') (Right _) = Right (ct, msg')
|
||||
combineResults _ (Left e) _ = Left e
|
||||
|
@ -2662,7 +2666,7 @@ processChatCommand' vr = \case
|
|||
ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq
|
||||
ctMsgReq ChangedProfileContact {conn} =
|
||||
fmap $ \SndMessage {msgId, msgBody} ->
|
||||
(conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, [msgId])
|
||||
(conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId]))
|
||||
updateContactPrefs :: User -> Contact -> Preferences -> CM ChatResponse
|
||||
updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct
|
||||
updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
|
||||
|
@ -2713,7 +2717,7 @@ processChatCommand' vr = \case
|
|||
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g)
|
||||
when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved
|
||||
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
|
||||
delGroupChatItemsForMembers :: User -> GroupInfo -> [GroupMember] -> [CChatItem CTGroup] -> CM ChatResponse
|
||||
delGroupChatItemsForMembers :: User -> GroupInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM ChatResponse
|
||||
delGroupChatItemsForMembers user gInfo ms items = do
|
||||
assertDeletable gInfo items
|
||||
assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate
|
||||
|
@ -2723,8 +2727,8 @@ processChatCommand' vr = \case
|
|||
delGroupChatItems user gInfo items True
|
||||
where
|
||||
assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM ()
|
||||
assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items =
|
||||
unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete
|
||||
assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' =
|
||||
unless (all itemDeletable items') $ throwChatError CEInvalidChatItemDelete
|
||||
where
|
||||
itemDeletable :: CChatItem 'CTGroup -> Bool
|
||||
itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) =
|
||||
|
@ -3742,8 +3746,8 @@ chatCommandP =
|
|||
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP),
|
||||
"/_archive reports #" *> (APIArchiveReceivedReports <$> A.decimal),
|
||||
"/_delete reports #" *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP),
|
||||
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP),
|
||||
"/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP),
|
||||
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> (knownReaction <$?> jsonP)),
|
||||
"/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> (knownReaction <$?> jsonP)),
|
||||
"/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP),
|
||||
"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP),
|
||||
"/_read user " *> (APIUserRead <$> A.decimal),
|
||||
|
|
|
@ -274,7 +274,7 @@ uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0
|
|||
go acc seen n (name : rest)
|
||||
| n >= maxMentions = acc
|
||||
| otherwise = case M.lookup name mentions of
|
||||
Just mm@MsgMention {memberId} | S.notMember memberId seen ->
|
||||
Just mm@MsgMention {memberId} | S.notMember memberId seen ->
|
||||
go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest
|
||||
_ -> go acc seen n rest
|
||||
|
||||
|
@ -1312,7 +1312,7 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do
|
|||
let batched_ = batchSndMessagesJSON msgs_
|
||||
case L.nonEmpty batched_ of
|
||||
Just batched' -> do
|
||||
let msgReqs = L.map (fmap (msgBatchReq conn msgFlags)) batched'
|
||||
let msgReqs = L.map (fmap msgBatchReq_) batched'
|
||||
delivered <- deliverMessagesB msgReqs
|
||||
let msgs' = concat $ L.zipWith flattenMsgs batched' delivered
|
||||
pqEnc = findLastPQEnc delivered
|
||||
|
@ -1320,6 +1320,9 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do
|
|||
pure (msgs', pqEnc)
|
||||
Nothing -> pure ([], Nothing)
|
||||
where
|
||||
msgBatchReq_ :: MsgBatch -> ChatMsgReq
|
||||
msgBatchReq_ (MsgBatch batchBody sndMsgs) =
|
||||
(conn, msgFlags, (vrValue batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs))
|
||||
flattenMsgs :: Either ChatError MsgBatch -> Either ChatError ([Int64], PQEncryption) -> [Either ChatError SndMessage]
|
||||
flattenMsgs (Right (MsgBatch _ sndMsgs)) (Right _) = map Right sndMsgs
|
||||
flattenMsgs (Right (MsgBatch _ sndMsgs)) (Left ce) = replicate (length sndMsgs) (Left ce)
|
||||
|
@ -1330,9 +1333,6 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do
|
|||
batchSndMessagesJSON :: NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch]
|
||||
batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList
|
||||
|
||||
msgBatchReq :: Connection -> MsgFlags -> MsgBatch -> ChatMsgReq
|
||||
msgBatchReq conn msgFlags (MsgBatch batchBody sndMsgs) = (conn, msgFlags, batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs)
|
||||
|
||||
encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString
|
||||
encodeConnInfo chatMsgEvent = do
|
||||
vr <- chatVersionRange
|
||||
|
@ -1358,7 +1358,7 @@ deliverMessage conn cmEventTag msgBody msgId = do
|
|||
|
||||
deliverMessage' :: Connection -> MsgFlags -> MsgBody -> MessageId -> CM (Int64, PQEncryption)
|
||||
deliverMessage' conn msgFlags msgBody msgId =
|
||||
deliverMessages ((conn, msgFlags, msgBody, [msgId]) :| []) >>= \case
|
||||
deliverMessages ((conn, msgFlags, (vrValue msgBody, [msgId])) :| []) >>= \case
|
||||
r :| [] -> case r of
|
||||
Right ([deliveryId], pqEnc) -> pure (deliveryId, pqEnc)
|
||||
Right (deliveryIds, _) -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 delivery id, got " <> show (length deliveryIds)
|
||||
|
@ -1366,45 +1366,45 @@ deliverMessage' conn msgFlags msgBody msgId =
|
|||
rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs)
|
||||
|
||||
-- [MessageId] - SndMessage ids inside MsgBatch, or single message id
|
||||
type ChatMsgReq = (Connection, MsgFlags, MsgBody, [MessageId])
|
||||
type ChatMsgReq = (Connection, MsgFlags, (ValueOrRef MsgBody, [MessageId]))
|
||||
|
||||
deliverMessages :: NonEmpty ChatMsgReq -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption)))
|
||||
deliverMessages msgs = deliverMessagesB $ L.map Right msgs
|
||||
|
||||
deliverMessagesB :: NonEmpty (Either ChatError ChatMsgReq) -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption)))
|
||||
deliverMessagesB msgReqs = do
|
||||
msgReqs' <- liftIO compressBodies
|
||||
msgReqs' <- if any connSupportsPQ msgReqs then liftIO compressBodies else pure msgReqs
|
||||
sent <- L.zipWith prepareBatch msgReqs' <$> withAgent (`sendMessagesB` snd (mapAccumL toAgent Nothing msgReqs'))
|
||||
lift . void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent)
|
||||
lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent
|
||||
where
|
||||
connSupportsPQ = \case
|
||||
Right (Connection {pqSupport = PQSupportOn, connChatVersion = v}, _, _) -> v >= pqEncryptionCompressionVersion
|
||||
_ -> False
|
||||
compressBodies =
|
||||
forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgIds) ->
|
||||
runExceptT $ case pqSupport of
|
||||
-- we only compress messages when:
|
||||
-- 1) PQ support is enabled
|
||||
-- 2) version is compatible with compression
|
||||
-- 3) message is longer than max compressed size (as this function is not used for batched messages anyway)
|
||||
PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do
|
||||
forME msgReqs $ \(conn, msgFlags, (mbr, msgIds)) -> runExceptT $ do
|
||||
mbr' <- case mbr of
|
||||
VRValue i msgBody | B.length msgBody > maxCompressedMsgLength -> do
|
||||
let msgBody' = compressedBatchMsgBody_ msgBody
|
||||
when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message"
|
||||
pure (conn, msgFlags, msgBody', msgIds)
|
||||
_ -> pure mr
|
||||
pure $ VRValue i msgBody'
|
||||
v -> pure v
|
||||
pure (conn, msgFlags, (mbr', msgIds))
|
||||
toAgent prev = \case
|
||||
Right (conn@Connection {connId, pqEncryption}, msgFlags, msgBody, _msgIds) ->
|
||||
Right (conn@Connection {connId, pqEncryption}, msgFlags, (mbr, _msgIds)) ->
|
||||
let cId = case prev of
|
||||
Just prevId | prevId == connId -> ""
|
||||
_ -> aConnId conn
|
||||
in (Just connId, Right (cId, pqEncryption, msgFlags, msgBody))
|
||||
in (Just connId, Right (cId, pqEncryption, msgFlags, mbr))
|
||||
Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it
|
||||
prepareBatch (Right req) (Right ar) = Right (req, ar)
|
||||
prepareBatch (Left ce) _ = Left ce -- restore original ChatError
|
||||
prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing
|
||||
createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption))
|
||||
createDelivery db ((Connection {connId}, _, _, msgIds), (agentMsgId, pqEnc')) = do
|
||||
createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do
|
||||
Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds
|
||||
updatePQSndEnabled :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO ()
|
||||
updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _), (_, pqSndEnabled')) =
|
||||
updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _), (_, pqSndEnabled')) =
|
||||
case (pqSndEnabled, pqSndEnabled') of
|
||||
(Just b, b') | b' /= b -> updatePQ
|
||||
(Nothing, PQEncOn) -> updatePQ
|
||||
|
@ -1471,7 +1471,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do
|
|||
stored <- lift $ withStoreBatch (\db -> map (bindRight $ createPendingMsg db) pendingReqs)
|
||||
when (length stored /= length pendingMemIds) $ logError "sendGroupMessages_: pendingMemIds and stored length mismatch"
|
||||
-- Zip for easier access to results
|
||||
let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, _, msgIds) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered
|
||||
let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, (_, msgIds)) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered
|
||||
pending = zipWith3 (\mId pReq r -> (mId, fmap snd pReq, r)) pendingMemIds pendingReqs stored
|
||||
pure (sndMsgs_, GroupSndResult {sentTo, pending, forwarded})
|
||||
where
|
||||
|
@ -1495,24 +1495,36 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do
|
|||
mId = groupMemberId' m
|
||||
mIds' = S.insert mId mIds
|
||||
prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
prepareMsgReqs msgFlags msgs_ toSendSeparate toSendBatched = do
|
||||
let batched_ = batchSndMessagesJSON msgs_
|
||||
prepareMsgReqs msgFlags msgs toSendSeparate toSendBatched = do
|
||||
let batched_ = batchSndMessagesJSON msgs
|
||||
case L.nonEmpty batched_ of
|
||||
Just batched' -> do
|
||||
let (memsSep, mreqsSep) = foldr' foldMsgs ([], []) toSendSeparate
|
||||
(memsBtch, mreqsBtch) = foldr' (foldBatches batched') ([], []) toSendBatched
|
||||
let lenMsgs = length msgs
|
||||
(memsSep, mreqsSep) = foldMembers lenMsgs sndMessageMBR msgs toSendSeparate
|
||||
(memsBtch, mreqsBtch) = foldMembers (length batched' + lenMsgs) msgBatchMBR batched' toSendBatched
|
||||
(memsSep <> memsBtch, mreqsSep <> mreqsBtch)
|
||||
Nothing -> ([], [])
|
||||
where
|
||||
foldMsgs :: (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
foldMsgs (GroupMember {groupMemberId}, conn) memIdsReqs =
|
||||
foldr' (\msg_ (memIds, reqs) -> (groupMemberId : memIds, fmap sndMessageReq msg_ : reqs)) memIdsReqs msgs_
|
||||
foldMembers :: forall a. Int -> (Maybe Int -> Int -> a -> (ValueOrRef MsgBody, [MessageId])) -> NonEmpty (Either ChatError a) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
foldMembers lastRef mkMb mbs mems = snd $ foldr' foldMsgBodies (lastMemIdx_, ([], [])) mems
|
||||
where
|
||||
sndMessageReq :: SndMessage -> ChatMsgReq
|
||||
sndMessageReq SndMessage {msgId, msgBody} = (conn, msgFlags, msgBody, [msgId])
|
||||
foldBatches :: NonEmpty (Either ChatError MsgBatch) -> (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
foldBatches batched' (GroupMember {groupMemberId}, conn) memIdsReqs =
|
||||
foldr' (\batch_ (memIds, reqs) -> (groupMemberId : memIds, fmap (msgBatchReq conn msgFlags) batch_ : reqs)) memIdsReqs batched'
|
||||
lastMemIdx_ = let len = length mems in if len > 1 then Just len else Nothing
|
||||
foldMsgBodies :: (GroupMember, Connection) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq]))
|
||||
foldMsgBodies (GroupMember {groupMemberId}, conn) (memIdx_, memIdsReqs) =
|
||||
(subtract 1 <$> memIdx_,) $ snd $ foldr' addBody (lastRef, memIdsReqs) mbs
|
||||
where
|
||||
addBody :: Either ChatError a -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq]))
|
||||
addBody mb (i, (memIds, reqs)) =
|
||||
let req = (conn,msgFlags,) . mkMb memIdx_ i <$> mb
|
||||
in (i - 1, (groupMemberId : memIds, req : reqs))
|
||||
sndMessageMBR :: Maybe Int -> Int -> SndMessage -> (ValueOrRef MsgBody, [MessageId])
|
||||
sndMessageMBR memIdx_ i SndMessage {msgId, msgBody} = (vrValue_ memIdx_ i msgBody, [msgId])
|
||||
msgBatchMBR :: Maybe Int -> Int -> MsgBatch -> (ValueOrRef MsgBody, [MessageId])
|
||||
msgBatchMBR memIdx_ i (MsgBatch batchBody sndMsgs) = (vrValue_ memIdx_ i batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs)
|
||||
vrValue_ memIdx_ i v = case memIdx_ of
|
||||
Nothing -> VRValue Nothing v -- sending to one member, do not reference bodies
|
||||
Just 1 -> VRValue (Just i) v
|
||||
Just _ -> VRRef i
|
||||
preparePending :: NonEmpty (Either ChatError SndMessage) -> [GroupMember] -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)])
|
||||
preparePending msgs_ =
|
||||
foldr' foldMsgs ([], [])
|
||||
|
|
|
@ -274,7 +274,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
|||
map (\fileDescr -> (conn, (connOrGroupId, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText)
|
||||
toMsgReq :: (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq
|
||||
toMsgReq (conn, _) SndMessage {msgId, msgBody} =
|
||||
(conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, msgBody, [msgId])
|
||||
(conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId]))
|
||||
sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM ()
|
||||
sendFileError ferr err vr ft = do
|
||||
logError $ "Sent file error: " <> err
|
||||
|
|
|
@ -425,6 +425,13 @@ data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json
|
|||
emojiTag :: IsString a => a
|
||||
emojiTag = "emoji"
|
||||
|
||||
knownReaction :: MsgReaction -> Either String MsgReaction
|
||||
knownReaction = \case
|
||||
r@MREmoji {} -> Right r
|
||||
MRUnknown {} -> Left "unknown MsgReaction"
|
||||
|
||||
-- parseJSON for MsgReaction parses unknown emoji reactions as MRUnknown with type "emoji",
|
||||
-- allowing to add new emojis in a backwards compatible way - UI shows them as ?
|
||||
instance FromJSON MsgReaction where
|
||||
parseJSON (J.Object v) = do
|
||||
tag <- v .: "type"
|
||||
|
|
|
@ -15,6 +15,20 @@ SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
|
|||
SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT
|
||||
m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, m.internal_snd_id, s.previous_msg_hash,
|
||||
s.retry_int_slow, s.retry_int_fast, s.msg_encrypt_key, s.padded_msg_len, sb.agent_msg
|
||||
FROM messages m
|
||||
JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id
|
||||
LEFT JOIN snd_message_bodies sb ON sb.snd_message_body_id = s.snd_message_body_id
|
||||
WHERE m.conn_id = ? AND m.internal_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?)
|
||||
SEARCH s USING PRIMARY KEY (conn_id=?)
|
||||
SEARCH sb USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
|
||||
|
||||
Query:
|
||||
SELECT
|
||||
r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries,
|
||||
|
@ -45,16 +59,6 @@ Query:
|
|||
Plan:
|
||||
SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?)
|
||||
|
||||
Query:
|
||||
SELECT m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, s.retry_int_slow, s.retry_int_fast
|
||||
FROM messages m
|
||||
JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id
|
||||
WHERE m.conn_id = ? AND m.internal_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?)
|
||||
SEARCH s USING PRIMARY KEY (conn_id=?)
|
||||
|
||||
Query:
|
||||
SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path
|
||||
FROM rcv_file_chunks
|
||||
|
@ -71,6 +75,17 @@ Query:
|
|||
Plan:
|
||||
SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?)
|
||||
|
||||
Query:
|
||||
DELETE FROM snd_message_bodies
|
||||
WHERE NOT EXISTS (SELECT 1 FROM snd_messages WHERE snd_message_body_id = ?)
|
||||
AND snd_message_body_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH snd_message_bodies USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SCALAR SUBQUERY 1
|
||||
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?)
|
||||
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?)
|
||||
|
||||
Query:
|
||||
SELECT
|
||||
f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path,
|
||||
|
@ -184,6 +199,16 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
|
|||
SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
|
||||
USE TEMP B-TREE FOR ORDER BY
|
||||
|
||||
Query:
|
||||
SELECT rcpt_status, snd_message_body_id FROM snd_messages
|
||||
WHERE NOT EXISTS (SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0)
|
||||
AND conn_id = ? AND internal_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH snd_messages USING PRIMARY KEY (conn_id=?)
|
||||
SCALAR SUBQUERY 1
|
||||
SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?)
|
||||
|
||||
Query:
|
||||
SELECT rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, deleted, redirect_id, redirect_entity_id, redirect_size, redirect_digest
|
||||
FROM rcv_files
|
||||
|
@ -512,9 +537,9 @@ Plan:
|
|||
|
||||
Query:
|
||||
INSERT INTO snd_messages
|
||||
( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash)
|
||||
( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash, msg_encrypt_key, padded_msg_len, snd_message_body_id)
|
||||
VALUES
|
||||
(?,?,?,?,?)
|
||||
(?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_id=? AND internal_snd_id=?)
|
||||
|
@ -861,6 +886,10 @@ Plan:
|
|||
Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT INTO snd_message_bodies (agent_msg) VALUES (?) RETURNING snd_message_body_id
|
||||
Plan:
|
||||
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?)
|
||||
|
||||
Query: INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) VALUES (?, ?, ?)
|
||||
Plan:
|
||||
|
||||
|
@ -893,9 +922,9 @@ Query: SELECT conn_id FROM connections WHERE user_id = ?
|
|||
Plan:
|
||||
SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?)
|
||||
|
||||
Query: SELECT count(*) FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0
|
||||
Query: SELECT count(1) FROM snd_message_bodies
|
||||
Plan:
|
||||
SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?)
|
||||
SCAN snd_message_bodies
|
||||
|
||||
Query: SELECT deleted FROM snd_files WHERE snd_file_id = ?
|
||||
Plan:
|
||||
|
@ -921,10 +950,6 @@ Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM rat
|
|||
Plan:
|
||||
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
|
||||
|
||||
Query: SELECT rcpt_internal_id, rcpt_status FROM snd_messages WHERE conn_id = ? AND internal_id = ?
|
||||
Plan:
|
||||
SEARCH snd_messages USING PRIMARY KEY (conn_id=?)
|
||||
|
||||
Query: SELECT rcv_file_id FROM rcv_files WHERE rcv_file_entity_id = ?
|
||||
Plan:
|
||||
SEARCH rcv_files USING COVERING INDEX sqlite_autoindex_rcv_files_1 (rcv_file_entity_id=?)
|
||||
|
@ -1089,6 +1114,10 @@ Query: UPDATE snd_messages SET retry_int_slow = ?, retry_int_fast = ? WHERE conn
|
|||
Plan:
|
||||
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
|
||||
|
||||
Query: UPDATE snd_messages SET snd_message_body_id = NULL WHERE conn_id = ? AND internal_id = ?
|
||||
Plan:
|
||||
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
|
||||
|
||||
Query: UPDATE snd_queues SET snd_primary = ? WHERE conn_id = ?
|
||||
Plan:
|
||||
SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{-# LANGUAGE TupleSections #-}
|
||||
{-# OPTIONS_GHC -Wno-orphans #-}
|
||||
|
||||
module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where
|
||||
module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, zipWith3') where
|
||||
|
||||
import Control.Exception (Exception)
|
||||
import Control.Monad
|
||||
|
@ -15,6 +15,7 @@ import Control.Monad.Reader
|
|||
import Data.Bifunctor (first)
|
||||
import qualified Data.ByteString.Lazy as LB
|
||||
import Data.List (sortBy)
|
||||
import Data.List.NonEmpty (NonEmpty (..))
|
||||
import Data.Ord (comparing)
|
||||
import Data.Time (NominalDiffTime)
|
||||
import Data.Word (Word16)
|
||||
|
@ -52,6 +53,9 @@ shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) x
|
|||
random :: IO Word16
|
||||
random = randomRIO (0, 65535)
|
||||
|
||||
zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d
|
||||
zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs
|
||||
|
||||
liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a
|
||||
liftIOEither a = liftIO a >>= liftEither
|
||||
{-# INLINE liftIOEither #-}
|
||||
|
|
|
@ -259,13 +259,13 @@ createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do
|
|||
Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix
|
||||
insertUser agentStore
|
||||
Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True
|
||||
startTestChat_ db cfg opts user
|
||||
startTestChat_ ps db cfg opts user
|
||||
|
||||
startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC
|
||||
startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do
|
||||
Right db@ChatDatabase {chatStore} <- createDatabase ps coreOptions dbPrefix
|
||||
Just user <- find activeUser <$> withTransaction chatStore getUsers
|
||||
startTestChat_ db cfg opts user
|
||||
startTestChat_ ps db cfg opts user
|
||||
|
||||
createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase)
|
||||
#if defined(dbPostgres)
|
||||
|
@ -282,8 +282,8 @@ insertUser :: DBStore -> IO ()
|
|||
insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)")
|
||||
#endif
|
||||
|
||||
startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC
|
||||
startTestChat_ db cfg opts user = do
|
||||
startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC
|
||||
startTestChat_ TestParams {printOutput} db cfg opts user = do
|
||||
t <- withVirtualTerminal termSettings pure
|
||||
ct <- newChatTerminal t opts
|
||||
cc <- newChatController db (Just user) cfg opts False
|
||||
|
@ -292,7 +292,7 @@ startTestChat_ db cfg opts user = do
|
|||
atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry
|
||||
termQ <- newTQueueIO
|
||||
termAsync <- async $ readTerminalOutput t termQ
|
||||
pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput = False}
|
||||
pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput}
|
||||
|
||||
stopTestChat :: TestParams -> TestCC -> IO ()
|
||||
stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do
|
||||
|
@ -355,10 +355,10 @@ withTestChatOpts ps = withTestChatCfgOpts ps testCfg
|
|||
withTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a
|
||||
withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts dbPrefix) (\cc -> cc <// 100000 >> stopTestChat ps cc)
|
||||
|
||||
-- enable output for specific chat controller, use like this:
|
||||
-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ...
|
||||
withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a
|
||||
withTestOutput cc runTest = runTest cc {printOutput = True}
|
||||
-- enable output for specific test.
|
||||
-- usage: withTestOutput $ testChat2 aliceProfile bobProfile $ \alice bob -> do ...
|
||||
withTestOutput :: HasCallStack => (HasCallStack => TestParams -> IO ()) -> TestParams -> IO ()
|
||||
withTestOutput test ps = test ps {printOutput = True}
|
||||
|
||||
readTerminalOutput :: VirtualTerminal -> TQueue String -> IO ()
|
||||
readTerminalOutput t termQ = do
|
||||
|
@ -404,12 +404,12 @@ testChatN cfg opts ps test params = do
|
|||
(<//) cc t = timeout t (getTermLine cc) `shouldReturn` Nothing
|
||||
|
||||
getTermLine :: HasCallStack => TestCC -> IO String
|
||||
getTermLine cc =
|
||||
getTermLine cc@TestCC {printOutput} =
|
||||
5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case
|
||||
Just s -> do
|
||||
-- remove condition to always echo virtual terminal
|
||||
-- when True $ do
|
||||
when (printOutput cc) $ do
|
||||
when printOutput $ do
|
||||
name <- userName cc
|
||||
putStrLn $ name <> ": " <> s
|
||||
pure s
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
module ChatTests.DBUtils.Postgres where
|
||||
|
||||
data TestParams = TestParams
|
||||
{ tmpPath :: FilePath
|
||||
{ tmpPath :: FilePath,
|
||||
printOutput :: Bool
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import Simplex.Messaging.TMap (TMap)
|
|||
|
||||
data TestParams = TestParams
|
||||
{ tmpPath :: FilePath,
|
||||
printOutput :: Bool,
|
||||
chatQueryStats :: TMap Query SlowQueryStats,
|
||||
agentQueryStats :: TMap Query SlowQueryStats
|
||||
}
|
||||
|
|
|
@ -83,6 +83,8 @@ chatGroupTests = do
|
|||
it "send multiple messages api" testSendMulti
|
||||
it "send multiple timed messages" testSendMultiTimed
|
||||
it "send multiple messages (many chat batches)" testSendMultiManyBatches
|
||||
xit'' "shared message body is reused" testSharedMessageBody
|
||||
xit'' "shared batch body is reused" testSharedBatchBody
|
||||
describe "async group connections" $ do
|
||||
xit "create and join group when clients go offline" testGroupAsync
|
||||
describe "group links" $ do
|
||||
|
@ -1602,7 +1604,7 @@ testGroupModerate =
|
|||
|
||||
testGroupModerateOwn :: HasCallStack => TestParams -> IO ()
|
||||
testGroupModerateOwn =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
withTestOutput $ testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
-- disableFullDeletion2 "team" alice bob
|
||||
|
@ -1883,6 +1885,112 @@ testSendMultiManyBatches =
|
|||
DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdCath) :: IO [[Int]]
|
||||
cathItemsCount `shouldBe` [[300]]
|
||||
|
||||
testSharedMessageBody :: HasCallStack => TestParams -> IO ()
|
||||
testSharedMessageBody ps =
|
||||
withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do
|
||||
withSmpServer' serverCfg' $
|
||||
withNewTestChatOpts ps opts' "bob" bobProfile $ \bob ->
|
||||
withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do
|
||||
createGroup3 "team" alice bob cath
|
||||
|
||||
alice <##. "server disconnected localhost"
|
||||
alice #> "#team hello"
|
||||
bodiesCount1 <- withCCAgentTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]]
|
||||
bodiesCount1 `shouldBe` [[1]]
|
||||
|
||||
withSmpServer' serverCfg' $
|
||||
withTestChatOpts ps opts' "bob" $ \bob ->
|
||||
withTestChatOpts ps opts' "cath" $ \cath -> do
|
||||
concurrentlyN_
|
||||
[ alice <##. "server connected localhost",
|
||||
do
|
||||
bob <## "1 contacts connected (use /cs for the list)"
|
||||
bob <## "#team: connected to server(s)",
|
||||
do
|
||||
cath <## "1 contacts connected (use /cs for the list)"
|
||||
cath <## "#team: connected to server(s)"
|
||||
]
|
||||
bob <# "#team alice> hello"
|
||||
cath <# "#team alice> hello"
|
||||
bodiesCount2 <- withCCAgentTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]]
|
||||
bodiesCount2 `shouldBe` [[0]]
|
||||
|
||||
alice <##. "server disconnected localhost"
|
||||
where
|
||||
tmp = tmpPath ps
|
||||
serverCfg' =
|
||||
smpServerCfg
|
||||
{ transports = [("7003", transport @TLS, False)],
|
||||
storeLogFile = Just $ tmp <> "/smp-server-store.log",
|
||||
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
|
||||
}
|
||||
opts' =
|
||||
testOpts
|
||||
{ coreOptions =
|
||||
testCoreOpts
|
||||
{ smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"]
|
||||
}
|
||||
}
|
||||
|
||||
testSharedBatchBody :: HasCallStack => TestParams -> IO ()
|
||||
testSharedBatchBody ps =
|
||||
withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do
|
||||
withSmpServer' serverCfg' $
|
||||
withNewTestChatOpts ps opts' "bob" bobProfile $ \bob ->
|
||||
withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do
|
||||
createGroup3 "team" alice bob cath
|
||||
|
||||
alice <##. "server disconnected localhost"
|
||||
|
||||
let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}"
|
||||
cms = intercalate ", " (map cm [1 .. 300 :: Int])
|
||||
alice `send` ("/_send #1 json [" <> cms <> "]")
|
||||
_ <- getTermLine alice
|
||||
alice <## "300 messages sent"
|
||||
|
||||
bodiesCount1 <- withCCAgentTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]]
|
||||
bodiesCount1 `shouldBe` [[3]]
|
||||
|
||||
withSmpServer' serverCfg' $
|
||||
withTestChatOpts ps opts' "bob" $ \bob ->
|
||||
withTestChatOpts ps opts' "cath" $ \cath -> do
|
||||
concurrentlyN_
|
||||
[ alice <##. "server connected localhost",
|
||||
do
|
||||
bob <## "1 contacts connected (use /cs for the list)"
|
||||
bob <## "#team: connected to server(s)",
|
||||
do
|
||||
cath <## "1 contacts connected (use /cs for the list)"
|
||||
cath <## "#team: connected to server(s)"
|
||||
]
|
||||
forM_ [(1 :: Int) .. 300] $ \i -> do
|
||||
concurrently_
|
||||
(bob <# ("#team alice> message " <> show i))
|
||||
(cath <# ("#team alice> message " <> show i))
|
||||
bodiesCount2 <- withCCAgentTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]]
|
||||
bodiesCount2 `shouldBe` [[0]]
|
||||
|
||||
alice <##. "server disconnected localhost"
|
||||
where
|
||||
tmp = tmpPath ps
|
||||
serverCfg' =
|
||||
smpServerCfg
|
||||
{ transports = [("7003", transport @TLS, False)],
|
||||
storeLogFile = Just $ tmp <> "/smp-server-store.log",
|
||||
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
|
||||
}
|
||||
opts' =
|
||||
testOpts
|
||||
{ coreOptions =
|
||||
testCoreOpts
|
||||
{ smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"]
|
||||
}
|
||||
}
|
||||
|
||||
testGroupAsync :: HasCallStack => TestParams -> IO ()
|
||||
testGroupAsync ps = do
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
||||
|
|
|
@ -742,7 +742,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice
|
|||
(biz <# "#bob bob_1> hey there")
|
||||
|
||||
testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO ()
|
||||
testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $
|
||||
testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile aliceProfile bobProfile cathProfile $
|
||||
\biz alice bob cath -> do
|
||||
biz ##> "/ad"
|
||||
cLink <- getContactLink biz True
|
||||
|
@ -756,8 +756,11 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c
|
|||
alice <## "#biz: joining the group..."
|
||||
biz <# "#alice Welcome" -- auto reply
|
||||
biz <## "#alice: alice_1 joined the group"
|
||||
alice <# "#biz biz_1> Welcome"
|
||||
alice <## "#biz: you joined the group"
|
||||
alice
|
||||
<###
|
||||
[ WithTime "#biz biz_1> Welcome",
|
||||
"#biz: you joined the group"
|
||||
]
|
||||
biz #> "#alice hi"
|
||||
alice <# "#biz biz_1> hi"
|
||||
alice #> "#biz hello"
|
||||
|
|
|
@ -33,6 +33,7 @@ import Simplex.Chat.Types
|
|||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.FileTransfer.Client.Main (xftpClientCLI)
|
||||
import Simplex.Messaging.Agent.Client (agentClientStore)
|
||||
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
|
@ -556,6 +557,10 @@ withCCTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a
|
|||
withCCTransaction cc action =
|
||||
withTransaction (chatStore $ chatController cc) $ \db -> action db
|
||||
|
||||
withCCAgentTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a
|
||||
withCCAgentTransaction TestCC {chatController = ChatController {smpAgent}} action =
|
||||
withTransaction (agentClientStore smpAgent) $ \db -> action db
|
||||
|
||||
createCCNoteFolder :: TestCC -> IO ()
|
||||
createCCNoteFolder cc =
|
||||
withCCTransaction cc $ \db ->
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
|
||||
|
@ -75,10 +76,10 @@ main = do
|
|||
#endif
|
||||
where
|
||||
#if defined(dbPostgres)
|
||||
testBracket test = withSmpServer $ tmpBracket $ test . TestParams
|
||||
testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False}
|
||||
#else
|
||||
testBracket chatQueryStats agentQueryStats test =
|
||||
withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats}
|
||||
withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False}
|
||||
#endif
|
||||
tmpBracket test = do
|
||||
t <- getSystemTime
|
||||
|
|
Loading…
Add table
Reference in a new issue