Skip to content
Open
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
11 changes: 11 additions & 0 deletions NextcloudTalk/Chat/BaseChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ import Toast
let isAtBottom = self.shouldScrollOnNewMessages()
let keyDate = self.dateSections[indexPath.section]
updatedMessage.isGroupMessage = message.isGroupMessage && message.actorType != "bots" && updatedMessage.lastEditTimestamp == 0
updatedMessage.copyPendingReactions(from: message)
self.messages[keyDate]?[indexPath.row] = updatedMessage

// Check if there are any messages that reference our message as a parent -> these need to be reloaded as well
Expand Down Expand Up @@ -3138,6 +3139,16 @@ import Toast

guard let (indexPath, message) = self.indexPathAndMessage(forMessageId: message.messageId) else { return }

// .added and .removed only confirm a pending reaction, there is nothing to draw or to
// create if the server state already landed (e.g. chat relay reaction system message)
if state == .added || state == .removed {
if message.hasTemporaryReaction(reaction) {
message.setOrUpdateTemporaryReaction(reaction, state: state)
}

return
}

message.setOrUpdateTemporaryReaction(reaction, state: state)

CATransaction.begin()
Expand Down
30 changes: 27 additions & 3 deletions NextcloudTalk/Chat/NCChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,29 @@ import SwiftyAttributes
}
}

/// Copies the reactions of `message` the server has not confirmed yet. A message built from the
/// database has none of its own, so without this our reaction disappears until the server sends it.
public func copyPendingReactions(from message: NCChatMessage) {
let ownReactions = self.reactionsSelfArray()

let pendingReactions = message.temporaryReactions().compactMap({ $0 as? NCChatReaction }).filter { reaction in
switch reaction.state {
case .adding, .added:
return !ownReactions.contains(reaction.reaction)
case .removing, .removed:
return ownReactions.contains(reaction.reaction)
default:
return false
}
}

self.temporaryReactions().addObjects(from: pendingReactions)
}

public func hasTemporaryReaction(_ reaction: String) -> Bool {
return temporaryReactions().compactMap({ $0 as? NCChatReaction }).contains { $0.reaction == reaction }
}

public func setOrUpdateTemporaryReaction(_ reaction: String, state: NCChatReactionState) {
if let updateReaction = temporaryReactions().compactMap({ $0 as? NCChatReaction }).first(where: { $0.reaction == reaction }) {
updateReaction.reaction = reaction
Expand Down Expand Up @@ -513,9 +536,6 @@ import SwiftyAttributes
managedChatMessage.systemMessage = chatMessage.systemMessage
managedChatMessage.isReplyable = chatMessage.isReplyable
managedChatMessage.messageType = chatMessage.messageType
// Reactions we already know keep their position, new ones are appended
managedChatMessage.reactionsJSONString = NCChatMessage.reactionsJSONString(for: chatMessage.storedReactions(),
keepingOrderOf: managedChatMessage.storedReactions())
managedChatMessage.expirationTimestamp = chatMessage.expirationTimestamp
managedChatMessage.isMarkdownMessage = chatMessage.isMarkdownMessage
managedChatMessage.lastEditActorId = chatMessage.lastEditActorId
Expand All @@ -529,6 +549,10 @@ import SwiftyAttributes
managedChatMessage.pinnedAt = chatMessage.pinnedAt

if !isRoomLastMessage {
// Reactions we already know keep their position, new ones are appended. Both fields are
// written together, the room's last message has counts but never our own reactions.
managedChatMessage.reactionsJSONString = NCChatMessage.reactionsJSONString(for: chatMessage.storedReactions(),
keepingOrderOf: managedChatMessage.storedReactions())
managedChatMessage.reactionsSelfJSONString = chatMessage.reactionsSelfJSONString

// Only update the thread data if there is any data (e.g. omit chat relay messages without thread data)
Expand Down
64 changes: 64 additions & 0 deletions NextcloudTalkTests/Unit/Chat/UnitNCChatMessageTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,70 @@ final class UnitNCChatMessageTest: TestBaseRealm {
XCTAssertEqual(reactionOrder([["馃榾": 1, "馃憤": 1, "鉂わ笍": 1]]), ["鉂わ笍", "馃憤", "馃榾"])
}

/// A message as it comes back from the database
private func storedMessage(reactions: [String: Int], ownReactions: [String]) -> NCChatMessage {
let message = NCChatMessage()
let pairs = reactions.map { [$0.key, $0.value] as [Any] }
message.reactionsJSONString = String(data: try! JSONSerialization.data(withJSONObject: pairs), encoding: .utf8)
message.reactionsSelfJSONString = String(data: try! JSONSerialization.data(withJSONObject: ownReactions), encoding: .utf8)

return message
}

// An update in between replaces the message with the stored one, which doesn't know the reaction yet
func testReactionAddedButNotEchoedBackYetIsKept() throws {
let shownMessage = NCChatMessage()
shownMessage.setOrUpdateTemporaryReaction("馃憤", state: .adding)

let updatedMessage = storedMessage(reactions: [:], ownReactions: [])
updatedMessage.copyPendingReactions(from: shownMessage)

XCTAssertEqual(updatedMessage.reactionsArray().map { $0.reaction }, ["馃憤"],
"A reaction the server has not echoed back yet must stay on the message")
XCTAssertTrue(updatedMessage.reactionsArray().first?.userReacted == true)
}

func testReactionRemovedButNotEchoedBackYetStaysRemoved() throws {
let shownMessage = NCChatMessage()
shownMessage.setOrUpdateTemporaryReaction("馃憤", state: .removing)

// The server still has our reaction
let updatedMessage = storedMessage(reactions: ["馃憤": 1], ownReactions: ["馃憤"])
updatedMessage.copyPendingReactions(from: shownMessage)

XCTAssertTrue(updatedMessage.reactionsArray().isEmpty,
"A reaction we removed must not come back until the server says it is still there")
}

// Once confirmed the message carries the reaction itself, keeping it too would count it twice
func testEchoedBackReactionIsNoLongerKept() throws {
let shownMessage = NCChatMessage()
shownMessage.setOrUpdateTemporaryReaction("馃憤", state: .added)

let updatedMessage = storedMessage(reactions: ["馃憤": 2], ownReactions: ["馃憤"])
updatedMessage.copyPendingReactions(from: shownMessage)

XCTAssertEqual(updatedMessage.temporaryReactions().count, 0)
XCTAssertEqual(updatedMessage.reactionsArray().first?.count, 2, "The reaction must not be counted twice")
}

// Taking only the counts would leave our reaction unmarked, and counted again as a temporary one
func testRoomLastMessageUpdateDoesNotDesyncReactionsFromOurOwn() throws {
// We reacted, and are waiting for the server to confirm it
let shownMessage = NCChatMessage()
shownMessage.setOrUpdateTemporaryReaction("馃憤", state: .added)

// A room update arrives first: it counts our reaction, but carries no reactionsSelf
let message = storedMessage(reactions: [:], ownReactions: [])
let roomLastMessage = storedMessage(reactions: ["馃憤": 1], ownReactions: [])
NCChatMessage.update(message, with: roomLastMessage, isRoomLastMessage: true)

message.copyPendingReactions(from: shownMessage)

XCTAssertEqual(message.reactionsArray().first?.count, 1,
"Our reaction must not be counted both by the room update and as a temporary one")
}

// Messages stored before reactions were kept in order hold a JSON object instead of pairs
func testReactionsStoredInTheOldFormatAreStillRead() throws {
let message = NCChatMessage()
Expand Down
Loading