diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa584de5e8..4510c2f2909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ⚡ Performance -- Improve performance of `ChatChannel` database model conversions more than 7 times [#3325](https://github.com/GetStream/stream-chat-swift/pull/3325) - Improve performance of `ChatChannel` and `ChatMessage` equality checks [#3335](https://github.com/GetStream/stream-chat-swift/pull/3335) ### ✅ Added - Expose `MissingConnectionId` + `InvalidURL` + `InvalidJSON` Errors [#3332](https://github.com/GetStream/stream-chat-swift/pull/3332) diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index a46fdcfc1f0..955aeb503e6 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -445,75 +445,70 @@ extension ChatChannel { ) extraData = [:] } - - let sortedMessageDTOs = dto.messages.sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate }) + let reads: [ChatChannelRead] = try dto.reads.map { try $0.asModel() } + let unreadCount: ChannelUnreadCount = { - guard let currentUserDTO = context.currentUser else { + guard let currentUser = context.currentUser else { return .noUnread } - let currentUserRead = reads.first(where: { $0.user.id == currentUserDTO.user.id }) + + let currentUserRead = reads.first(where: { $0.user.id == currentUser.user.id }) + let allUnreadMessages = currentUserRead?.unreadMessagesCount ?? 0 - // Therefore, no unread messages with mentions and we can skip the fetch - if allUnreadMessages == 0 { + + // Fetch count of all mentioned messages after last read + // (this is not 100% accurate but it's the best we have) + let unreadMentionsRequest = NSFetchRequest(entityName: MessageDTO.entityName) + unreadMentionsRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + MessageDTO.channelMessagesPredicate( + for: dto.cid, + deletedMessagesVisibility: context.deletedMessagesVisibility ?? .visibleForCurrentUser, + shouldShowShadowedMessages: context.shouldShowShadowedMessages ?? false + ), + NSPredicate(format: "createdAt > %@", currentUserRead?.lastReadAt.bridgeDate ?? DBDate(timeIntervalSince1970: 0)), + NSPredicate(format: "%@ IN mentionedUsers", currentUser.user) + ]) + + do { + return ChannelUnreadCount( + messages: allUnreadMessages, + mentions: try context.count(for: unreadMentionsRequest) + ) + } catch { + log.error("Failed to fetch unread counts for channel `\(cid)`. Error: \(error)") return .noUnread } - let unreadMentionsCount = sortedMessageDTOs - .prefix(allUnreadMessages) - .filter { $0.mentionedUsers.contains(currentUserDTO.user) } - .count - return ChannelUnreadCount( - messages: allUnreadMessages, - mentions: unreadMentionsCount - ) }() - let latestMessages: [ChatMessage] = { - var messages = sortedMessageDTOs - .prefix(dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25) + let messages: [ChatMessage] = { + MessageDTO + .load( + for: dto.cid, + limit: dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25, + deletedMessagesVisibility: dto.managedObjectContext?.deletedMessagesVisibility ?? .visibleForCurrentUser, + shouldShowShadowedMessages: dto.managedObjectContext?.shouldShowShadowedMessages ?? false, + context: context + ) .compactMap { try? $0.relationshipAsModel(depth: depth) } - if let oldest = dto.oldestMessageAt?.bridgeDate { - messages = messages.filter { $0.createdAt >= oldest } - } - if let truncated = dto.truncatedAt?.bridgeDate { - messages = messages.filter { $0.createdAt >= truncated } - } - return messages }() let latestMessageFromUser: ChatMessage? = { - guard let currentUserId = context.currentUser?.user.id else { return nil } - return try? sortedMessageDTOs - .first(where: { messageDTO in - guard messageDTO.user.id == currentUserId else { return false } - guard messageDTO.localMessageState == nil else { return false } - return messageDTO.type != MessageType.ephemeral.rawValue - })? + guard let currentUser = context.currentUser else { return nil } + + return try? MessageDTO + .loadLastMessage( + from: currentUser.user.id, + in: dto.cid, + context: context + )? .relationshipAsModel(depth: depth) }() - - let watchers = dto.watchers - .sorted { lhs, rhs in - let lhsActivity = lhs.lastActivityAt?.bridgeDate ?? .distantPast - let rhsActivity = rhs.lastActivityAt?.bridgeDate ?? .distantPast - if lhsActivity == rhsActivity { - return lhs.id > rhs.id - } - return lhsActivity > rhsActivity - } - .prefix(context.localCachingSettings?.chatChannel.lastActiveWatchersLimit ?? 100) + + let watchers = UserDTO.loadLastActiveWatchers(cid: cid, context: context) .compactMap { try? $0.asModel() } - let members = dto.members - .sorted { lhs, rhs in - let lhsActivity = lhs.user.lastActivityAt?.bridgeDate ?? .distantPast - let rhsActivity = rhs.user.lastActivityAt?.bridgeDate ?? .distantPast - if lhsActivity == rhsActivity { - return lhs.id > rhs.id - } - return lhsActivity > rhsActivity - } - .prefix(context.localCachingSettings?.chatChannel.lastActiveMembersLimit ?? 100) + let members = MemberDTO.loadLastActiveMembers(cid: cid, context: context) .compactMap { try? $0.asModel() } let muteDetails: MuteDetails? = { @@ -555,7 +550,7 @@ extension ChatChannel { reads: reads, cooldownDuration: Int(dto.cooldownDuration), extraData: extraData, - latestMessages: latestMessages, + latestMessages: messages, lastMessageFromCurrentUser: latestMessageFromUser, pinnedMessages: pinnedMessages, muteDetails: muteDetails, diff --git a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift index d869a6e8c4f..f9f58da5d09 100644 --- a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift @@ -92,6 +92,17 @@ extension MemberDTO { new.id = memberId return new } + + static func loadLastActiveMembers(cid: ChannelId, context: NSManagedObjectContext) -> [MemberDTO] { + let request = NSFetchRequest(entityName: MemberDTO.entityName) + request.predicate = NSPredicate(format: "channel.cid == %@", cid.rawValue) + request.sortDescriptors = [ + ChannelMemberListSortingKey.lastActiveSortDescriptor, + ChannelMemberListSortingKey.defaultSortDescriptor + ] + request.fetchLimit = context.localCachingSettings?.chatChannel.lastActiveMembersLimit ?? 100 + return load(by: request, context: context) + } } extension NSManagedObjectContext { diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 81b05497ff1..853b6ec1ef1 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -516,6 +516,19 @@ class MessageDTO: NSManagedObject { return (try? context.count(for: request)) ?? 0 } + static func loadLastMessage(from userId: String, in cid: String, context: NSManagedObjectContext) -> MessageDTO? { + let request = NSFetchRequest(entityName: entityName) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + channelPredicate(with: cid), + .init(format: "user.id == %@", userId), + .init(format: "type != %@", MessageType.ephemeral.rawValue), + messageSentPredicate() + ]) + request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)] + request.fetchLimit = 1 + return load(by: request, context: context).first + } + static func loadSendingMessages(context: NSManagedObjectContext) -> [MessageDTO] { let request = NSFetchRequest(entityName: MessageDTO.entityName) request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.locallyCreatedAt, ascending: false)] @@ -1296,28 +1309,21 @@ private extension ChatMessage { if let currentUser = context.currentUser { isSentByCurrentUser = currentUser.user.id == dto.user.id - if !dto.ownReactions.isEmpty { - currentUserReactions = Set( - MessageReactionDTO - .loadReactions(ids: dto.ownReactions, context: context) - .compactMap { try? $0.asModel() } - ) - } else { - currentUserReactions = [] - } + currentUserReactions = Set( + MessageReactionDTO + .loadReactions(ids: dto.ownReactions, context: context) + .compactMap { try? $0.asModel() } + ) } else { isSentByCurrentUser = false currentUserReactions = [] } - latestReactions = { - guard !dto.latestReactions.isEmpty else { return Set() } - return Set( - MessageReactionDTO - .loadReactions(ids: dto.latestReactions, context: context) - .compactMap { try? $0.asModel() } - ) - }() + latestReactions = Set( + MessageReactionDTO + .loadReactions(ids: dto.latestReactions, context: context) + .compactMap { try? $0.asModel() } + ) threadParticipants = dto.threadParticipants.array .compactMap { $0 as? UserDTO } @@ -1331,10 +1337,8 @@ private extension ChatMessage { .sorted { $0.id.index < $1.id.index } latestReplies = { - guard dto.replyCount > 0 else { return [] } - return dto.replies - .sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate }) - .prefix(5) + guard !dto.replies.isEmpty else { return [] } + return MessageDTO.loadReplies(for: dto.id, limit: 5, context: context) .compactMap { try? ChatMessage(fromDTO: $0, depth: depth) } }() diff --git a/Sources/StreamChat/Database/DTOs/UserDTO.swift b/Sources/StreamChat/Database/DTOs/UserDTO.swift index 483fe21e457..2d6430c30ad 100644 --- a/Sources/StreamChat/Database/DTOs/UserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/UserDTO.swift @@ -115,6 +115,17 @@ extension UserDTO { new.teams = [] return new } + + static func loadLastActiveWatchers(cid: ChannelId, context: NSManagedObjectContext) -> [UserDTO] { + let request = NSFetchRequest(entityName: UserDTO.entityName) + request.sortDescriptors = [ + UserListSortingKey.lastActiveSortDescriptor, + UserListSortingKey.defaultSortDescriptor + ] + request.predicate = NSPredicate(format: "ANY watchedChannels.cid == %@", cid.rawValue) + request.fetchLimit = context.localCachingSettings?.chatChannel.lastActiveWatchersLimit ?? 100 + return load(by: request, context: context) + } } extension NSManagedObjectContext: UserDatabaseSession { diff --git a/Sources/StreamChat/Query/Sorting/ChannelMemberListSortingKey.swift b/Sources/StreamChat/Query/Sorting/ChannelMemberListSortingKey.swift index 9ccfb8ade38..1125b4a01e3 100644 --- a/Sources/StreamChat/Query/Sorting/ChannelMemberListSortingKey.swift +++ b/Sources/StreamChat/Query/Sorting/ChannelMemberListSortingKey.swift @@ -33,6 +33,11 @@ extension ChannelMemberListSortingKey { return .init(keyPath: dateKeyPath, ascending: false) }() + static let lastActiveSortDescriptor: NSSortDescriptor = { + let dateKeyPath: KeyPath = \MemberDTO.user.lastActivityAt + return .init(keyPath: dateKeyPath, ascending: false) + }() + func sortDescriptor(isAscending: Bool) -> NSSortDescriptor { .init(key: rawValue, ascending: isAscending) } diff --git a/Sources/StreamChat/Query/Sorting/UserListSortingKey.swift b/Sources/StreamChat/Query/Sorting/UserListSortingKey.swift index 9b618c5a13c..4237ccdea03 100644 --- a/Sources/StreamChat/Query/Sorting/UserListSortingKey.swift +++ b/Sources/StreamChat/Query/Sorting/UserListSortingKey.swift @@ -39,6 +39,11 @@ extension UserListSortingKey { return .init(keyPath: stringKeyPath, ascending: false) }() + static let lastActiveSortDescriptor: NSSortDescriptor = { + let dateKeyPath: KeyPath = \UserDTO.lastActivityAt + return .init(keyPath: dateKeyPath, ascending: false) + }() + func sortDescriptor(isAscending: Bool) -> NSSortDescriptor? { .init(key: rawValue, ascending: isAscending) } diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index d685b17d7e5..3721b639345 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -1322,7 +1322,7 @@ final class ChannelDTO_Tests: XCTestCase { XCTAssertEqual(channel.unreadCount.messages, 0) } - func test_asModel_populatesLatestMessage_withoutFilteringDeletedMessages() throws { + func test_asModel_populatesLatestMessage() throws { // GIVEN database = DatabaseContainer_Spy( kind: .inMemory, @@ -1411,7 +1411,7 @@ final class ChannelDTO_Tests: XCTestCase { // THEN XCTAssertEqual( Set(channel.latestMessages.map(\.id)), - Set([message1.id, deletedMessageFromCurrentUser.id, deletedMessageFromAnotherUser.id]) + Set([message1.id, deletedMessageFromCurrentUser.id, shadowedMessageFromAnotherUser.id]) ) }