From 66b5cc73aeff79791b32fb2eaad87b384d4502a9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 22 Oct 2024 13:04:23 +0100 Subject: [PATCH 1/2] [ECO-4944] feat: add room level reaction implementation --- .../src/main/java/com/ably/chat/Room.kt | 3 +- .../main/java/com/ably/chat/RoomReactions.kt | 49 +++++++- .../src/main/java/com/ably/chat/Utils.kt | 15 +++ .../java/com/ably/chat/RoomReactionsTest.kt | 109 ++++++++++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt diff --git a/chat-android/src/main/java/com/ably/chat/Room.kt b/chat-android/src/main/java/com/ably/chat/Room.kt index 22d91f3..0ff81d9 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -105,7 +105,8 @@ internal class DefaultRoom( override val reactions: RoomReactions = DefaultRoomReactions( roomId = roomId, - realtimeClient = realtimeClient, + clientId = realtimeClient.auth.clientId, + realtimeChannels = realtimeClient.channels, ) override val typing: Typing = DefaultTyping( diff --git a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt index 06b214c..f58b712 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -2,7 +2,12 @@ package com.ably.chat +import com.google.gson.JsonObject +import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.MessageExtras /** * This interface is used to interact with room-level reactions in a chat room: subscribing to reactions and sending them. @@ -100,19 +105,53 @@ data class SendReactionParams( internal class DefaultRoomReactions( roomId: String, - private val realtimeClient: RealtimeClient, + private val clientId: String, + realtimeChannels: AblyRealtime.Channels, ) : RoomReactions { + // (CHA-ER1) private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" - override val channel: Channel - get() = realtimeClient.channels.get(roomReactionsChannelName, ChatChannelOptions()) + override val channel: Channel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) + // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. + // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. override suspend fun send(params: SendReactionParams) { - TODO("Not yet implemented") + val pubSubMessage = PubSubMessage().apply { + data = JsonObject().apply { + addProperty("type", params.type) + params.metadata?.let { add("metadata", it.toJson()) } + } + params.headers?.let { + extras = MessageExtras( + JsonObject().apply { + add("headers", it.toJson()) + }, + ) + } + } + channel.publishCoroutine(pubSubMessage) } override fun subscribe(listener: RoomReactions.Listener): Subscription { - TODO("Not yet implemented") + val messageListener = PubSubMessageListener { + val pubSubMessage = it ?: throw AblyException.fromErrorInfo( + ErrorInfo("Got empty pubsub channel message", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ) + val data = pubSubMessage.data as? JsonObject ?: throw AblyException.fromErrorInfo( + ErrorInfo("Unrecognized Pub/Sub channel's message for `roomReaction` event", HttpStatusCodes.InternalServerError), + ) + val reaction = Reaction( + type = data.requireString("type"), + createdAt = pubSubMessage.timestamp, + clientId = pubSubMessage.clientId, + metadata = data.get("metadata")?.toMap() ?: mapOf(), + headers = pubSubMessage.extras.asJsonObject().get("headers")?.toMap() ?: mapOf(), + isSelf = pubSubMessage.clientId == clientId, + ) + listener.onReaction(reaction) + } + channel.subscribe(RoomReactionEventType.Reaction.eventName, messageListener) + return Subscription { channel.unsubscribe(RoomReactionEventType.Reaction.eventName, messageListener) } } override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { diff --git a/chat-android/src/main/java/com/ably/chat/Utils.kt b/chat-android/src/main/java/com/ably/chat/Utils.kt index a915530..8f57352 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -35,6 +35,21 @@ suspend fun Channel.detachCoroutine() = suspendCoroutine { continuation -> }) } +suspend fun Channel.publishCoroutine(message: PubSubMessage) = suspendCoroutine { continuation -> + publish( + message, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + @Suppress("FunctionName") fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { val options = ChannelOptions() diff --git a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt new file mode 100644 index 0000000..182c4e9 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt @@ -0,0 +1,109 @@ +package com.ably.chat + +import com.google.gson.JsonObject +import io.ably.lib.realtime.AblyRealtime.Channels +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.buildRealtimeChannel +import io.ably.lib.types.MessageExtras +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class RoomReactionsTest { + private val realtimeChannels = mockk(relaxed = true) + private val realtimeChannel = spyk(buildRealtimeChannel("room1::\$chat::\$reactions")) + private lateinit var roomReactions: DefaultRoomReactions + + @Before + fun setUp() { + every { realtimeChannels.get(any(), any()) } answers { + val channelName = firstArg() + if (channelName == "room1::\$chat::\$reactions") { + realtimeChannel + } else { + buildRealtimeChannel(channelName) + } + } + + roomReactions = DefaultRoomReactions( + roomId = "room1", + clientId = "client1", + realtimeChannels = realtimeChannels, + ) + } + + /** + * @spec CHA-ER1 + */ + @Test + fun `channel name is set according to the spec`() = runTest { + val roomReactions = DefaultRoomReactions( + roomId = "foo", + clientId = "client1", + realtimeChannels = realtimeChannels, + ) + + assertEquals( + "foo::\$chat::\$reactions", + roomReactions.channel.name, + ) + } + + /** + * @spec CHA-ER3a + */ + @Test + fun `should be able to subscribe to incoming reactions`() = runTest { + val pubSubMessageListenerSlot = slot() + + every { realtimeChannel.subscribe("roomReaction", capture(pubSubMessageListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + roomReactions.subscribe { + deferredValue.completeWith(it) + } + + verify { realtimeChannel.subscribe("roomReaction", any()) } + + pubSubMessageListenerSlot.captured.onMessage( + PubSubMessage().apply { + data = JsonObject().apply { + addProperty("type", "like") + } + clientId = "clientId" + timestamp = 1000L + extras = MessageExtras( + JsonObject().apply { + add( + "headers", + JsonObject().apply { + addProperty("foo", "bar") + }, + ) + }, + ) + }, + ) + + val reaction = deferredValue.await() + + assertEquals( + Reaction( + type = "like", + createdAt = 1000L, + clientId = "clientId", + metadata = mapOf(), + headers = mapOf("foo" to "bar"), + isSelf = false, + ), + reaction, + ) + } +} From 7d14c1c1c29890b2922208d88cdbe2bffa4444c5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 23 Oct 2024 18:12:00 +0100 Subject: [PATCH 2/2] [ECO-4944] feat: update example app --- .../main/java/com/ably/chat/RoomReactions.kt | 3 +- example/build.gradle.kts | 1 + .../com/ably/chat/example/MainActivity.kt | 67 +++++++++++++++---- gradle/libs.versions.toml | 2 + 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt index f58b712..3fab956 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -117,6 +117,7 @@ internal class DefaultRoomReactions( // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. override suspend fun send(params: SendReactionParams) { val pubSubMessage = PubSubMessage().apply { + name = RoomReactionEventType.Reaction.eventName data = JsonObject().apply { addProperty("type", params.type) params.metadata?.let { add("metadata", it.toJson()) } @@ -145,7 +146,7 @@ internal class DefaultRoomReactions( createdAt = pubSubMessage.timestamp, clientId = pubSubMessage.clientId, metadata = data.get("metadata")?.toMap() ?: mapOf(), - headers = pubSubMessage.extras.asJsonObject().get("headers")?.toMap() ?: mapOf(), + headers = pubSubMessage.extras?.asJsonObject()?.get("headers")?.toMap() ?: mapOf(), isSelf = pubSubMessage.clientId == clientId, ) listener.onReaction(reaction) diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 2307a66..dc7b801 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.konfetti.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/example/src/main/java/com/ably/chat/example/MainActivity.kt b/example/src/main/java/com/ably/chat/example/MainActivity.kt index d12596a..4a851c0 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -37,6 +37,7 @@ import com.ably.chat.ChatClient import com.ably.chat.Message import com.ably.chat.RealtimeClient import com.ably.chat.SendMessageParams +import com.ably.chat.SendReactionParams import com.ably.chat.example.ui.theme.AblyChatExampleTheme import io.ably.lib.types.ClientOptions import java.util.UUID @@ -72,6 +73,7 @@ class MainActivity : ComponentActivity() { } } +@SuppressWarnings("LongMethod") @Composable fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { var messageText by remember { mutableStateOf(TextFieldValue("")) } @@ -79,10 +81,22 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { var messages by remember { mutableStateOf(listOf()) } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + var receivedReactions by remember { mutableStateOf>(listOf()) } val roomId = "my-room" val room = chatClient.rooms.get(roomId) + DisposableEffect(Unit) { + coroutineScope.launch { + room.attach() + } + onDispose { + coroutineScope.launch { + room.detach() + } + } + } + DisposableEffect(Unit) { val subscription = room.messages.subscribe { messages += it.message @@ -101,6 +115,16 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { } } + DisposableEffect(Unit) { + val subscription = room.reactions.subscribe { + receivedReactions += it.type + } + + onDispose { + subscription.unsubscribe() + } + } + Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, @@ -119,17 +143,26 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { sending = sending, messageInput = messageText, onMessageChange = { messageText = it }, - ) { - sending = true - coroutineScope.launch { - room.messages.send( - SendMessageParams( - text = messageText.text, - ), - ) - messageText = TextFieldValue("") - sending = false - } + onSendClick = { + sending = true + coroutineScope.launch { + room.messages.send( + SendMessageParams( + text = messageText.text, + ), + ) + messageText = TextFieldValue("") + sending = false + } + }, + onReactionClick = { + coroutineScope.launch { + room.reactions.send(SendReactionParams(type = "\uD83D\uDC4D")) + } + }, + ) + if (receivedReactions.isNotEmpty()) { + Text("Received reactions: ${receivedReactions.joinToString()}", modifier = Modifier.padding(16.dp)) } } } @@ -164,6 +197,7 @@ fun ChatInputField( messageInput: TextFieldValue, onMessageChange: (TextFieldValue) -> Unit, onSendClick: () -> Unit, + onReactionClick: () -> Unit, ) { Row( modifier = Modifier @@ -181,8 +215,14 @@ fun ChatInputField( .background(Color.White), placeholder = { Text("Type a message...") }, ) - Button(enabled = !sending, onClick = onSendClick) { - Text("Send") + if (messageInput.text.isNotEmpty()) { + Button(enabled = !sending, onClick = onSendClick) { + Text("Send") + } + } else { + Button(onClick = onReactionClick) { + Text("\uD83D\uDC4D") + } } } } @@ -214,6 +254,7 @@ fun ChatInputPreview() { messageInput = TextFieldValue(""), onMessageChange = {}, onSendClick = {}, + onReactionClick = {}, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3817db2..35bce2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ ably = "1.2.43" junit = "4.13.2" agp = "8.5.2" detekt = "1.23.6" +konfetti-compose = "2.0.4" kotlin = "2.0.10" androidx-test = "1.6.1" androidx-junit = "1.2.1" @@ -43,6 +44,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +konfetti-compose = { module = "nl.dionsegijn:konfetti-compose", version.ref = "konfetti-compose" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }