diff --git a/Sources/Document/Change/Change.swift b/Sources/Document/Change/Change.swift index 07d6be53..3bfdd2e0 100644 --- a/Sources/Document/Change/Change.swift +++ b/Sources/Document/Change/Change.swift @@ -62,8 +62,8 @@ public struct Change { try $0.execute(root: root) } - if let actorID = self.id.getActorID() { - switch self.presenceChange { + if let presenceChange = self.presenceChange, let actorID = self.id.getActorID() { + switch presenceChange { case .put(let presence): presences[actorID] = presence default: diff --git a/Sources/Document/Document.swift b/Sources/Document/Document.swift index b76198a4..5b645306 100644 --- a/Sources/Document/Document.swift +++ b/Sources/Document/Document.swift @@ -123,13 +123,14 @@ public actor Document { throw YorkieError.documentRemoved(message: "\(self) is removed.") } - let clone = self.cloned - let context = ChangeContext(id: self.changeID.next(), root: clone.root, message: message) - guard let actorID = self.changeID.getActorID() else { throw YorkieError.unexpected(message: "actor ID is null.") } + // 01. Update the clone object and create a change. + let clone = self.cloned + let context = ChangeContext(id: self.changeID.next(), root: clone.root, message: message) + let proxy = JSONObject(target: clone.root.object, context: context) if self.presences[actorID] == nil { @@ -142,6 +143,7 @@ public actor Document { self.clone?.presences[actorID] = presence.presence + // 02. Update the root object and presences from changes. if context.hasChange { Logger.trace("trying to update a local change: \(self.toJSON())") @@ -150,10 +152,12 @@ public actor Document { self.localChanges.append(change) self.changeID = change.id - if change.hasOperations { + // 03. Publish the document change event. + // NOTE(chacha912): Check opInfos, which represent the actually executed operations. + if !opInfos.isEmpty { let changeInfo = ChangeInfo(message: change.message ?? "", operations: opInfos, - actorID: change.id.getActorID()) + actorID: actorID) let changeEvent = LocalChangeEvent(value: changeInfo) self.publish(changeEvent) } @@ -677,11 +681,13 @@ public actor Document { /** * `getPresences` returns the presences of online clients. */ - public func getPresences() -> [PeerElement] { + public func getPresences(_ excludeMyself: Bool = false) -> [PeerElement] { var presences = [PeerElement]() + let excludeID = excludeMyself == true ? self.changeID.getActorID() : nil + for clientID in self.onlineClients { - if let presence = getPresence(clientID) { + if clientID != excludeID, let presence = getPresence(clientID) { presences.append((clientID, presence)) } } diff --git a/Tests/Integration/PresenceTests.swift b/Tests/Integration/PresenceTests.swift index eefd85f5..59230ee1 100644 --- a/Tests/Integration/PresenceTests.swift +++ b/Tests/Integration/PresenceTests.swift @@ -660,4 +660,146 @@ final class PresenceSubscribeTests: XCTestCase { try await c2.deactivate() try await c3.deactivate() } + + func test_presence_my_presence_remains_after_update() async throws { + let c1 = Client(rpcAddress) + let c2 = Client(rpcAddress) + try await c1.activate() + try await c2.activate() + + let docKey = "\(Date().description)-\(self.description)".toDocKey + + let doc1 = Document(key: docKey) + try await c1.attach(doc1) + let doc2 = Document(key: docKey) + try await c2.attach(doc2) + + let startPath = [0, 1] + let endPath = [0, 3] + + try await doc1.update { root, presence in + root.tree = JSONTree(initialRoot: JSONTreeElementNode(type: "doc", children: [ + JSONTreeElementNode(type: "node", children: [ + JSONTreeTextNode(value: "Hello") + ])])) + + let range = try (root.tree as? JSONTree)?.pathRangeToPosRange((startPath, endPath)) + + presence.set(["start": range!.0, "end": range!.1]) + } + + var myPresence = await doc1.getMyPresence() + + var start: CRDTTreePosStruct = self.decodeDictionary(myPresence!["start"])! + var end: CRDTTreePosStruct = self.decodeDictionary(myPresence!["end"])! + + var converted = try await(doc1.getRoot().tree as? JSONTree)?.posRangeToPathRange((start, end)) + + XCTAssertEqual(converted!.0, startPath) + XCTAssertEqual(converted!.1, endPath) + + try await doc1.update { root, _ in + try (root.tree as? JSONTree)?.editByPath([0, 0], [0, 0], JSONTreeTextNode(value: "A")) + } + + myPresence = await doc1.getMyPresence() + + start = self.decodeDictionary(myPresence!["start"])! + end = self.decodeDictionary(myPresence!["end"])! + + converted = try await(doc1.getRoot().tree as? JSONTree)?.posRangeToPathRange((start, end)) + + XCTAssertEqual(converted!.0, [0, 2]) + XCTAssertEqual(converted!.1, [0, 4]) + + try await c1.deactivate() + try await c2.deactivate() + } + + func test_presence_updated_when_someone_edits() async throws { + let c1 = Client(rpcAddress) + let c2 = Client(rpcAddress) + try await c1.activate() + try await c2.activate() + let c1ID = await c1.id! + + let docKey = "\(Date().description)-\(self.description)".toDocKey + + let doc1 = Document(key: docKey) + try await c1.attach(doc1, [:], .realtimeSyncOff) + let doc2 = Document(key: docKey) + try await c2.attach(doc2, [:], .realtimeSyncOff) + + let startPath = [1, 0, 0, 2] + let endPath = [1, 0, 0, 3] + + try await doc1.update { root, presence in + root.tree = JSONTree(initialRoot: + JSONTreeElementNode(type: "doc", children: [ + JSONTreeElementNode(type: "Title", children: [ + JSONTreeElementNode(type: "unit", children: [ + JSONTreeElementNode(type: "paragraph", children: [ + JSONTreeElementNode(type: "node")])])]), + JSONTreeElementNode(type: "text-1", children: [ + JSONTreeElementNode(type: "paragraph", children: [ + JSONTreeElementNode(type: "node", children: [ + JSONTreeTextNode(value: "H"), + JSONTreeTextNode(value: "e"), + JSONTreeTextNode(value: "l"), + JSONTreeTextNode(value: "l"), + JSONTreeTextNode(value: "o") + ]) + ]) + ]) + ]) + ) + + let range = try (root.tree as? JSONTree)?.pathRangeToPosRange((startPath, endPath)) + + presence.set(["start": range!.0, "end": range!.1]) + } + + try await c1.sync() + try await c2.sync() + + var myPresence = await doc2.getPresence(c1ID) + + var start: CRDTTreePosStruct = self.decodeDictionary(myPresence!["start"])! + var end: CRDTTreePosStruct = self.decodeDictionary(myPresence!["end"])! + + var converted = try await(doc1.getRoot().tree as? JSONTree)?.posRangeToPathRange((start, end)) + + XCTAssertEqual(converted!.0, startPath) + XCTAssertEqual(converted!.1, endPath) + + try await doc1.update { root, _ in + try (root.tree as? JSONTree)?.editByPath([1, 0, 0, 1], [1, 0, 0, 1], JSONTreeTextNode(value: "ABC")) + } + + try await c1.sync() + try await c2.sync() + + myPresence = await doc2.getPresence(c1ID) + + start = self.decodeDictionary(myPresence!["start"])! + end = self.decodeDictionary(myPresence!["end"])! + + converted = try await(doc1.getRoot().tree as? JSONTree)?.posRangeToPathRange((start, end)) + + XCTAssertEqual(converted!.0, [1, 0, 0, 5]) + XCTAssertEqual(converted!.1, [1, 0, 0, 6]) + + try await c1.deactivate() + try await c2.deactivate() + } + + private func decodeDictionary(_ dictionary: Any?) -> CRDTTreePosStruct? { + guard let dictionary = dictionary as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: dictionary, options: []) + else { + return nil + } + + return try? JSONDecoder().decode(CRDTTreePosStruct.self, from: data) + } }