Skip to content

Commit

Permalink
Extends public API to allow users to update and delete their messages
Browse files Browse the repository at this point in the history
  • Loading branch information
umair-ably committed Feb 25, 2025
1 parent 4de6b82 commit 434133f
Show file tree
Hide file tree
Showing 14 changed files with 407 additions and 26 deletions.
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
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

0 comments on commit 434133f

Please sign in to comment.