Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CIS-978] Fix deleted messages are visible in threads #1238

Merged
merged 1 commit into from
Jun 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🐞 Fixed
- `ConnectionController` fires its `controllerDidChangeConnectionStatus` method only when the connection status actually changes [#1207](https://github.com/GetStream/stream-chat-swift/issues/1207)
- Fix cancelled ephemeral (giphy) messages and deleted messages are visible in threads [#1238](https://github.com/GetStream/stream-chat-swift/issues/1238)


# [4.0.0-beta.4](https://github.com/GetStream/stream-chat-swift/releases/tag/4.0.0-beta.4)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ final class MessageController_Tests: StressTestCase {

/// This test simulates a bug where the `message` and `replies` fields were not updated if they weren't
/// touched before calling synchronize.
func test_channelsAreFetched_afterCallingSynchronize() throws {
func test_messagesAreFetched_afterCallingSynchronize() throws {
// Simulate `synchronize` call
controller.synchronize()

Expand Down Expand Up @@ -228,6 +228,67 @@ final class MessageController_Tests: StressTestCase {
let bottomToTopIds = [reply1, reply2].sorted { $0.createdAt < $1.createdAt }.map(\.id)
XCTAssertEqual(controller.replies.map(\.id), bottomToTopIds)
}

/// This test was added because we forgot to exclude deleted messages when fetching replies.
/// Valid message for a thread is defined as:
/// - `parentId` correctly set,
/// - is not deleted, or current user owned non-ephemeral deleted,
/// - newer than channel's truncation date (if channel is truncated)
func test_replies_onlyIncludeValidMessages() throws {
// Create dummy channel
let cid = ChannelId.unique
let channel = dummyPayload(with: cid)
let truncatedDate = Date.unique

// Save channel
try client.databaseContainer.writeSynchronously {
let dto = try $0.saveChannel(payload: channel)
dto.truncatedAt = truncatedDate
}

// Insert parent message
try client.databaseContainer.createMessage(id: messageId, authorId: .unique, cid: cid, text: "Parent")

// Insert replies for parent message
let reply1: MessagePayload<NoExtraData> = .dummy(
messageId: .unique,
parentId: messageId,
showReplyInChannel: false,
authorUserId: .unique,
createdAt: .unique(after: truncatedDate)
)

// Insert the 2nd reply as deleted
let createdAt = Date.unique(after: truncatedDate)
let reply2: MessagePayload<NoExtraData> = .dummy(
messageId: .unique,
parentId: messageId,
showReplyInChannel: false,
authorUserId: .unique,
createdAt: createdAt,
deletedAt: .unique(after: createdAt)
)

// Insert 3rd reply before truncation date
let reply3: MessagePayload<NoExtraData> = .dummy(
messageId: .unique,
parentId: messageId,
showReplyInChannel: false,
authorUserId: .unique,
createdAt: .unique(before: truncatedDate)
)

// Save messages
try client.databaseContainer.writeSynchronously {
try $0.saveMessage(payload: reply1, for: cid)
try $0.saveMessage(payload: reply2, for: cid)
try $0.saveMessage(payload: reply3, for: cid)
}

// Check if the replies are correct
let ids = [reply1].map(\.id)
XCTAssertEqual(controller.replies.map(\.id), ids)
}

// MARK: - Delegate

Expand Down
51 changes: 34 additions & 17 deletions Sources/StreamChat/Database/DTOs/MessageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,9 @@ class MessageDTO: NSManagedObject {
return request
}

/// Returns predicate with channel messages and replies that should be shown in channel.
static func channelMessagesPredicate(for cid: String) -> NSCompoundPredicate {
let channelMessage = NSPredicate(
format: "channel.cid == %@", cid
)

let messageTypePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
.init(format: "type != %@", MessageType.reply.rawValue),
.init(format: "type == %@ AND showReplyInChannel == 1", MessageType.reply.rawValue)
])

let deletedMessagePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
/// Returns predicate for deleted messages which should be displayed or not.
private static func deletedMessagesPredicate() -> NSCompoundPredicate {
.init(orPredicateWithSubpredicates: [
// Non-deleted messages.
.init(format: "deletedAt == nil"),
// Deleted messages sent by current user excluding ephemeral ones.
Expand All @@ -111,11 +102,26 @@ class MessageDTO: NSManagedObject {
.init(format: "type != %@", MessageType.ephemeral.rawValue)
])
])

let nonTruncatedMessagePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
}

/// Returns predicate for displaying messages after the channel truncation date.
private static func nonTruncatedMessagesPredicate() -> NSCompoundPredicate {
.init(orPredicateWithSubpredicates: [
.init(format: "channel.truncatedAt == nil"),
.init(format: "createdAt > channel.truncatedAt")
])
}

/// Returns predicate with channel messages and replies that should be shown in channel.
static func channelMessagesPredicate(for cid: String) -> NSCompoundPredicate {
let channelMessage = NSPredicate(
format: "channel.cid == %@", cid
)

let messageTypePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
.init(format: "type != %@", MessageType.reply.rawValue),
.init(format: "type == %@ AND showReplyInChannel == 1", MessageType.reply.rawValue)
])

// Some pinned messages might be in the local database, but should not be fetched
// if they do not belong to the regular channel query.
Expand All @@ -127,12 +133,23 @@ class MessageDTO: NSManagedObject {
return .init(andPredicateWithSubpredicates: [
channelMessage,
messageTypePredicate,
deletedMessagePredicate,
nonTruncatedMessagePredicate,
deletedMessagesPredicate(),
nonTruncatedMessagesPredicate(),
ignoreOlderMessagesPredicate
])
}

/// Returns predicate with thread messages that should be shown in the thread.
static func threadRepliesPredicate(for messageId: MessageId) -> NSCompoundPredicate {
Comment on lines +142 to +143
Copy link
Contributor

@VojtaStavik VojtaStavik Jun 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this logic is very similar to channel messages, should we find a way how to share this logic to prevent similar inconsistencies in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually copy/pasted some predicates (like deleted messages) so it makes sense to refactor these common predicates, i'll check/do this 👍

let replyMessage = NSPredicate(format: "parentMessageId == %@", messageId)

return .init(andPredicateWithSubpredicates: [
replyMessage,
deletedMessagesPredicate(),
nonTruncatedMessagesPredicate()
])
}

/// Returns a fetch request for messages from the channel with the provided `cid`.
static func messagesFetchRequest(for cid: ChannelId, sortAscending: Bool = false) -> NSFetchRequest<MessageDTO> {
let request = NSFetchRequest<MessageDTO>(entityName: MessageDTO.entityName)
Expand All @@ -145,7 +162,7 @@ class MessageDTO: NSManagedObject {
static func repliesFetchRequest(for messageId: MessageId, sortAscending: Bool = false) -> NSFetchRequest<MessageDTO> {
let request = NSFetchRequest<MessageDTO>(entityName: MessageDTO.entityName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.defaultSortingKey, ascending: sortAscending)]
request.predicate = NSPredicate(format: "parentMessageId == %@", messageId)
request.predicate = threadRepliesPredicate(for: messageId)
return request
}

Expand Down