Skip to content

Commit

Permalink
Merge pull request #100 from ably/feature/message-edit-update
Browse files Browse the repository at this point in the history
[ECO-5196] Message edit/delete
  • Loading branch information
sacOO7 authored Feb 25, 2025
2 parents 71b20c4 + 096a593 commit 40d35bf
Show file tree
Hide file tree
Showing 26 changed files with 844 additions and 327 deletions.
134 changes: 81 additions & 53 deletions chat-android/src/main/java/com/ably/chat/ChatApi.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.ably.chat

import com.google.gson.JsonElement
import com.google.gson.JsonObject
import io.ably.lib.types.AblyException
import io.ably.lib.types.AsyncHttpPaginatedResponse
import io.ably.lib.types.ErrorInfo
Expand All @@ -13,7 +12,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine

private const val API_PROTOCOL_VERSION = 3
private const val PROTOCOL_VERSION_PARAM_NAME = "v"
private const val RESERVED_ABLY_CHAT_KEY = "ably-chat"
private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString())

internal class ChatApi(
Expand All @@ -35,85 +33,115 @@ internal class ChatApi(
method = "GET",
params = params,
) {
val latestActionName = it.requireJsonObject().get("action")?.asString
val latestAction = latestActionName?.let { name -> messageActionNameToAction[name] }

val messageJsonObject = it.requireJsonObject()
val latestAction = messageJsonObject.get(MessageProperty.Action)?.asString?.let { name -> messageActionNameToAction[name] }
val operation = messageJsonObject.getAsJsonObject(MessageProperty.Operation)
latestAction?.let { action ->
Message(
serial = it.requireString("serial"),
clientId = it.requireString("clientId"),
roomId = it.requireString("roomId"),
text = it.requireString("text"),
createdAt = it.requireLong("createdAt"),
metadata = it.asJsonObject.get("metadata"),
headers = it.asJsonObject.get("headers")?.toMap() ?: mapOf(),
serial = messageJsonObject.requireString(MessageProperty.Serial),
clientId = messageJsonObject.requireString(MessageProperty.ClientId),
roomId = messageJsonObject.requireString(MessageProperty.RoomId),
text = messageJsonObject.requireString(MessageProperty.Text),
createdAt = messageJsonObject.requireLong(MessageProperty.CreatedAt),
metadata = messageJsonObject.getAsJsonObject(MessageProperty.Metadata) ?: MessageMetadata(),
headers = messageJsonObject.get(MessageProperty.Headers)?.toMap() ?: mapOf(),
action = action,
version = messageJsonObject.requireString(MessageProperty.Version),
timestamp = messageJsonObject.requireLong(MessageProperty.Timestamp),
operation = buildMessageOperation(operation),
)
}
}
}

/**
* Send message to the Chat Backend
*
* @return sent message instance
* Spec: CHA-M3
*/
suspend fun sendMessage(roomId: String, params: SendMessageParams): Message {
validateSendMessageParams(params)

val body = JsonObject().apply {
addProperty("text", params.text)
// (CHA-M3b)
params.headers?.let {
add("headers", it.toJson())
}
// (CHA-M3b)
params.metadata?.let {
add("metadata", it)
}
}
val body = params.toJsonObject() // CHA-M3b

return makeAuthorizedRequest(
"/chat/v2/rooms/$roomId/messages",
"POST",
body,
)?.let {
// (CHA-M3a)
val serial = it.requireString(MessageProperty.Serial)
val createdAt = it.requireLong(MessageProperty.CreatedAt)
// CHA-M3a
Message(
serial = it.requireString("serial"),
serial = serial,
clientId = clientId,
roomId = roomId,
text = params.text,
createdAt = it.requireLong("createdAt"),
metadata = params.metadata,
createdAt = createdAt,
metadata = params.metadata ?: MessageMetadata(),
headers = params.headers ?: mapOf(),
action = MessageAction.MESSAGE_CREATE,
version = serial,
timestamp = createdAt,
operation = null,
)
} ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCode.InternalServerError))
} ?: throw serverError("Send message endpoint returned empty value") // CHA-M3e
}

private fun validateSendMessageParams(params: SendMessageParams) {
// (CHA-M3c)
if ((params.metadata as? JsonObject)?.has(RESERVED_ABLY_CHAT_KEY) == true) {
throw AblyException.fromErrorInfo(
ErrorInfo(
"Metadata contains reserved 'ably-chat' key",
HttpStatusCode.BadRequest,
ErrorCode.InvalidRequestBody.code,
),
/**
* Spec: CHA-M8
*/
suspend fun updateMessage(message: Message, params: UpdateMessageParams): Message {
val body = params.toJsonObject()
// CHA-M8c
return makeAuthorizedRequest(
"/chat/v2/rooms/${message.roomId}/messages/${message.serial}",
"PUT",
body,
)?.let {
val version = it.requireString(MessageProperty.Version)
val timestamp = it.requireLong(MessageProperty.Timestamp)
// CHA-M8b
Message(
serial = message.serial,
clientId = clientId,
roomId = message.roomId,
text = params.message.text,
createdAt = message.createdAt,
metadata = params.message.metadata ?: MessageMetadata(),
headers = params.message.headers ?: mapOf(),
action = MessageAction.MESSAGE_UPDATE,
version = version,
timestamp = timestamp,
operation = buildMessageOperation(clientId, params.description, params.metadata),
)
}
} ?: throw serverError("Update message endpoint returned empty value") // CHA-M8d
}

/**
* Spec: CHA-M9
*/
suspend fun deleteMessage(message: Message, params: DeleteMessageParams): Message {
val body = params.toJsonObject()

// (CHA-M3d)
if (params.headers?.keys?.any { it.startsWith(RESERVED_ABLY_CHAT_KEY) } == true) {
throw AblyException.fromErrorInfo(
ErrorInfo(
"Headers contains reserved key with reserved 'ably-chat' prefix",
HttpStatusCode.BadRequest,
ErrorCode.InvalidRequestBody.code,
),
return makeAuthorizedRequest(
"/chat/v2/rooms/${message.roomId}/messages/${message.serial}/delete",
"POST",
body,
)?.let {
val version = it.requireString(MessageProperty.Version)
val timestamp = it.requireLong(MessageProperty.Timestamp)
// CHA-M9b
Message(
serial = message.serial,
clientId = clientId,
roomId = message.roomId,
text = message.text,
createdAt = message.createdAt,
metadata = message.metadata,
headers = message.headers,
action = MessageAction.MESSAGE_DELETE,
version = version,
timestamp = timestamp,
operation = buildMessageOperation(clientId, params.description, params.metadata),
)
}
} ?: throw serverError("Delete message endpoint returned empty value") // CHA-M9c
}

/**
Expand All @@ -125,7 +153,7 @@ internal class ChatApi(
connections = it.requireInt("connections"),
presenceMembers = it.requireInt("presenceMembers"),
)
} ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCode.InternalServerError))
} ?: throw serverError("Occupancy endpoint returned empty value")
}

private suspend fun makeAuthorizedRequest(
Expand Down
2 changes: 0 additions & 2 deletions chat-android/src/main/java/com/ably/chat/ChatClient.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
@file:Suppress("NotImplementedDeclaration")

package com.ably.chat

import io.ably.lib.realtime.AblyRealtime
Expand Down
8 changes: 7 additions & 1 deletion chat-android/src/main/java/com/ably/chat/EventTypes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ enum class MessageEventType(val eventName: String) {
/**
* Realtime chat message names.
*/
object PubSubMessageNames {
object PubSubEventName {
/** Represents a regular chat message. */
const val ChatMessage = "chat.message"
}
Expand All @@ -42,6 +42,12 @@ internal val messageActionNameToAction = mapOf(
"message.summary" to MessageAction.MESSAGE_SUMMARY,
)

internal val messageActionToEventType = mapOf(
MessageAction.MESSAGE_CREATE to MessageEventType.Created,
MessageAction.MESSAGE_UPDATE to MessageEventType.Updated,
MessageAction.MESSAGE_DELETE to MessageEventType.Deleted,
)

/**
* Enum representing presence events.
*/
Expand Down
78 changes: 77 additions & 1 deletion chat-android/src/main/java/com/ably/chat/Message.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.ably.chat

import com.google.gson.JsonObject
import io.ably.lib.types.Message
import io.ably.lib.types.MessageAction

/**
Expand All @@ -18,6 +20,7 @@ typealias MessageMetadata = Metadata
data class Message(
/**
* The unique identifier of the message.
* Spec: CHA-M2d
*/
val serial: String,

Expand Down Expand Up @@ -53,7 +56,7 @@ data class Message(
* Do not use metadata for authoritative information. There is no server-side
* validation. When reading the metadata treat it like user input.
*/
val metadata: MessageMetadata?,
val metadata: MessageMetadata,

/**
* The headers of a chat message. Headers enable attaching extra info to a message,
Expand All @@ -72,6 +75,79 @@ data class Message(

/**
* The latest action of the message. This can be used to determine if the message was created, updated, or deleted.
* Spec: CHA-M10
*/
val action: MessageAction,

/**
* A unique identifier for the latest version of this message.
* Spec: CHA-M10a
*/
val version: String,

/**
* The timestamp at which this version was updated, deleted, or created.
*/
val timestamp: Long,

/**
* The details of the operation that modified the message. This is only set for update and delete actions. It contains
* information about the operation: the clientId of the user who performed the operation, a description, and metadata.
*/
val operation: Message.Operation? = null,
)

internal fun buildMessageOperation(jsonObject: JsonObject?): Message.Operation? {
if (jsonObject == null) {
return null
}
val operation = Message.Operation()
if (jsonObject.has(MessageOperationProperty.ClientId)) {
operation.clientId = jsonObject.get(MessageOperationProperty.ClientId).asString
}
if (jsonObject.has(MessageOperationProperty.Description)) {
operation.description = jsonObject.get(MessageOperationProperty.Description).asString
}
if (jsonObject.has(MessageOperationProperty.Metadata)) {
val metadataObject = jsonObject.getAsJsonObject(MessageOperationProperty.Metadata)
operation.metadata = mutableMapOf()
for ((key, value) in metadataObject.entrySet()) {
operation.metadata[key] = value.asString
}
}
return operation
}

internal fun buildMessageOperation(clientId: String, description: String?, metadata: Map<String, String>?): Message.Operation {
val operation = Message.Operation()
operation.clientId = clientId
operation.description = description
operation.metadata = metadata
return operation
}

/**
* MessageProperty object representing the properties of a message.
*/
internal object MessageProperty {
const val Serial = "serial"
const val ClientId = "clientId"
const val RoomId = "roomId"
const val Text = "text"
const val CreatedAt = "createdAt"
const val Metadata = "metadata"
const val Headers = "headers"
const val Action = "action"
const val Version = "version"
const val Timestamp = "timestamp"
const val Operation = "operation"
}

/**
* MessageOperationProperty object representing the properties of a message operation.
*/
internal object MessageOperationProperty {
const val ClientId = "clientId"
const val Description = "description"
const val Metadata = "metadata"
}
Loading

0 comments on commit 40d35bf

Please sign in to comment.