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

[ECO-5157] [ECO-5158] Edits and Deletes #227

Merged
merged 1 commit into from
Feb 26, 2025
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
6 changes: 3 additions & 3 deletions AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "ae94e678de73d162a8e71bc1ead0d2214bbaa8f4b538125924f637df3d9c00a2",
"originHash" : "a7fd516b21dd11d1575c27de0a0a5aea3b0f6aba5c90e1cfd50d8ec79450f8dd",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"revision" : "6239f6d5caacbff91e28e9a5c56f8a0f3cc1b662",
"version" : "1.2.39"
"revision" : "c5347707e4f9fb907df0e5c334de445899a79783",
"version" : "1.2.40"
}
},
{
Expand Down
5 changes: 4 additions & 1 deletion Example/AblyChatExample/Mocks/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ final class MockMessagesPaginatedResult: PaginatedResult {
text: MockStrings.randomPhrase(),
createdAt: Date(),
metadata: [:],
headers: [:]
headers: [:],
version: "",
timestamp: Date(),
operation: nil
)
}
}
Expand Down
20 changes: 17 additions & 3 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ actor MockChatClient: ChatClient {
}

nonisolated var clientID: String {
fatalError("Not yet implemented")
realtime.clientId ?? "AblyTest"
}
}

Expand Down Expand Up @@ -108,7 +108,10 @@ actor MockMessages: Messages {
text: MockStrings.randomPhrase(),
createdAt: Date(),
metadata: [:],
headers: [:]
headers: [:],
version: "",
timestamp: Date(),
operation: nil
)
}, interval: 3)
}
Expand All @@ -132,12 +135,23 @@ actor MockMessages: Messages {
text: params.text,
createdAt: Date(),
metadata: params.metadata ?? [:],
headers: params.headers ?? [:]
headers: params.headers ?? [:],
version: "",
timestamp: Date(),
operation: nil
)
mockSubscriptions.emit(message)
return message
}

func update(newMessage _: Message, description _: String?, metadata _: OperationMetadata?) async throws -> Message {
fatalError("Not yet implemented")
}

func delete(message _: Message, params _: DeleteMessageParams) async throws -> Message {
fatalError("Not yet implemented")
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
Expand Down
2 changes: 1 addition & 1 deletion Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
}

var clientId: String? {
fatalError("Not implemented")
"AblyTest"
}

let channels = Channels()
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "2e855624caf2790f0086192383ad61e930282bbc7a5da8b08c8dc8e1e986b194",
"originHash" : "f828ffcb0d1ca3449a984ac4d5096d3e92e6ec875e498549533480ed22dc812d",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"revision" : "6239f6d5caacbff91e28e9a5c56f8a0f3cc1b662",
"version" : "1.2.39"
"revision" : "c5347707e4f9fb907df0e5c334de445899a79783",
"version" : "1.2.40"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/ably/ably-cocoa",
from: "1.2.39"
from: "1.2.40"
),
.package(
url: "https://github.com/apple/swift-argument-parser",
Expand Down
115 changes: 111 additions & 4 deletions Sources/AblyChat/ChatAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ internal final class ChatAPI: Sendable {
}
}

internal struct MessageOperationResponse: JSONObjectDecodable {
internal let version: String
internal let timestamp: Int64

internal init(jsonObject: [String: JSONValue]) throws {
version = try jsonObject.stringValueForKey("version")
timestamp = try Int64(jsonObject.numberValueForKey("timestamp"))
}
}

internal typealias UpdateMessageResponse = MessageOperationResponse
internal typealias DeleteMessageResponse = MessageOperationResponse

// (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method.
// (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event).
internal func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message {
Expand All @@ -48,16 +61,110 @@ internal final class ChatAPI: Sendable {

// response.createdAt is in milliseconds, convert it to seconds
let createdAtInSeconds = TimeInterval(Double(response.createdAt) / 1000)

let createdAtDate = Date(timeIntervalSince1970: createdAtInSeconds)
let message = Message(
serial: response.serial,
action: .create,
clientID: clientId,
roomID: roomId,
text: params.text,
createdAt: Date(timeIntervalSince1970: createdAtInSeconds),
createdAt: createdAtDate,
metadata: params.metadata ?? [:],
headers: params.headers ?? [:]
headers: params.headers ?? [:],
version: response.serial,
timestamp: createdAtDate
)
return message
}

// (CHA-M8) A client must be able to update a message in a room.
// (CHA-M8a) A client may update a message via the Chat REST API by calling the update method.
internal func updateMessage(with modifiedMessage: Message, description: String?, metadata: OperationMetadata?) async throws -> Message {
guard let clientID = realtime.clientId else {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
}

let endpoint = "\(apiVersionV2)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)"
var body: [String: JSONValue] = [:]
let messageObject: [String: JSONValue] = [
"text": .string(modifiedMessage.text),
"metadata": .object(modifiedMessage.metadata),
"headers": .object(modifiedMessage.headers.mapValues(\.toJSONValue)),
]

body["message"] = .object(messageObject)

if let description {
body["description"] = .string(description)
}

if let metadata {
body["metadata"] = .object(metadata)
}

// (CHA-M8c) An update operation has PUT semantics. If a field is not specified in the update, it is assumed to be removed.
let response: UpdateMessageResponse = try await makeRequest(endpoint, method: "PUT", body: body)

// response.timestamp is in milliseconds, convert it to seconds
let timestampInSeconds = TimeInterval(Double(response.timestamp) / 1000)

// (CHA-M8b) When a message is updated successfully via the REST API, the caller shall receive a struct representing the Message in response, as if it were received via Realtime event.
let message = Message(
serial: modifiedMessage.serial,
action: .update,
clientID: modifiedMessage.clientID,
roomID: modifiedMessage.roomID,
text: modifiedMessage.text,
createdAt: modifiedMessage.createdAt,
metadata: modifiedMessage.metadata,
headers: modifiedMessage.headers,
version: response.version,
timestamp: Date(timeIntervalSince1970: timestampInSeconds),
operation: .init(
clientID: clientID,
description: description,
metadata: metadata
)
)
return message
}

// (CHA-M9) A client must be able to delete a message in a room.
// (CHA-M9a) A client may delete a message via the Chat REST API by calling the delete method.
internal func deleteMessage(message: Message, params: DeleteMessageParams) async throws -> Message {
let endpoint = "\(apiVersionV2)/rooms/\(message.roomID)/messages/\(message.serial)/delete"
var body: [String: JSONValue] = [:]

if let description = params.description {
body["description"] = .string(description)
}

if let metadata = params.metadata {
body["metadata"] = .object(metadata)
}

let response: DeleteMessageResponse = try await makeRequest(endpoint, method: "POST", body: body)

// response.timestamp is in milliseconds, convert it to seconds
let timestampInSeconds = TimeInterval(Double(response.timestamp) / 1000)

// (CHA-M9b) When a message is deleted successfully via the REST API, the caller shall receive a struct representing the Message in response, as if it were received via Realtime event.
let message = Message(
serial: message.serial,
action: .delete,
clientID: message.clientID,
roomID: message.roomID,
text: message.text,
createdAt: message.createdAt,
metadata: message.metadata,
headers: message.headers,
version: response.version,
timestamp: Date(timeIntervalSince1970: timestampInSeconds),
operation: .init(
clientID: message.clientID,
description: params.description,
metadata: params.metadata
)
)
return message
}
Expand All @@ -78,7 +185,7 @@ internal final class ChatAPI: Sendable {
do {
try realtime.request(method, path: url, params: [:], body: ablyCocoaBody, headers: [:]) { paginatedResponse, error in
if let error {
// (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call.
// (CHA-M3e & CHA-M8d & CHA-M9c) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call.
continuation.resume(throwing: ARTErrorInfo.create(from: error))
return
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ public actor DefaultChatClient: ChatClient {
}

public nonisolated var clientID: String {
fatalError("Not yet implemented")
guard let clientID = realtime.clientId else {
fatalError("Ensure your Realtime instance is initialized with a clientId.")
}
return clientID
}
}

Expand Down
33 changes: 31 additions & 2 deletions Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId")
}

guard let version = message.version else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without version")
}

let metadata = try data.optionalObjectValueForKey("metadata")

let headers: Headers? = if let headersJSONObject = try extras.optionalObjectValueForKey("headers") {
Expand All @@ -88,15 +92,19 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
return
}

let message = Message(
// `message.operation?.toChatOperation()` is throwing but the linter prefers putting the `try` on Message initialization instead of having it nested.
let message = try Message(
serial: serial,
action: action,
clientID: clientID,
roomID: self.roomID,
text: text,
createdAt: message.timestamp,
metadata: metadata ?? .init(),
headers: headers ?? .init()
headers: headers ?? .init(),
version: version,
timestamp: message.timestamp,
operation: message.operation?.toChatOperation()
)

messageSubscription.emit(message)
Expand Down Expand Up @@ -127,6 +135,14 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
try await chatAPI.sendMessage(roomId: roomID, params: params)
}

internal func update(newMessage: Message, description: String?, metadata: OperationMetadata?) async throws -> Message {
try await chatAPI.updateMessage(with: newMessage, description: description, metadata: metadata)
}

internal func delete(message: Message, params: DeleteMessageParams) async throws -> Message {
try await chatAPI.deleteMessage(message: message, params: params)
}

// (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle.
internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) async -> Subscription<DiscontinuityEvent> {
await featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy)
Expand Down Expand Up @@ -273,3 +289,16 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
case noReferenceToSelf
}
}

private extension ARTMessageOperation {
func toChatOperation() throws -> MessageOperation {
guard let clientId else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message where Operation clientId is nil")
}
return MessageOperation(
clientID: clientId,
description: descriptionText,
metadata: metadata != nil ? JSONValue(ablyCocoaData: metadata!).objectValue : nil
)
}
}
10 changes: 7 additions & 3 deletions Sources/AblyChat/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ public enum MessageAction: String, Sendable {
* Action applied to a new message.
*/
case create = "message.create"
case update = "message.update"
case delete = "message.delete"

internal static func fromRealtimeAction(_ action: ARTMessageAction) -> Self? {
switch action {
case .create:
.create
case .update:
.update
case .delete:
.delete
// ignore any other actions except `message.create` for now
case .update,
.delete,
.metaOccupancy,
case .metaOccupancy,
.messageSummary:
nil
@unknown default:
Expand Down
Loading