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-4944] feat: add room level reaction implementation #36

Merged
merged 2 commits into from
Nov 7, 2024
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
3 changes: 2 additions & 1 deletion chat-android/src/main/java/com/ably/chat/Room.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
50 changes: 45 additions & 5 deletions chat-android/src/main/java/com/ably/chat/RoomReactions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -100,19 +105,54 @@ 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 {
name = RoomReactionEventType.Reaction.eventName
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)
ttypic marked this conversation as resolved.
Show resolved Hide resolved
}

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),
)
ttypic marked this conversation as resolved.
Show resolved Hide resolved
val reaction = Reaction(
type = data.requireString("type"),
ttypic marked this conversation as resolved.
Show resolved Hide resolved
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 {
Expand Down
15 changes: 15 additions & 0 deletions chat-android/src/main/java/com/ably/chat/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
109 changes: 109 additions & 0 deletions chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt
Original file line number Diff line number Diff line change
@@ -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<Channels>(relaxed = true)
private val realtimeChannel = spyk<Channel>(buildRealtimeChannel("room1::\$chat::\$reactions"))
private lateinit var roomReactions: DefaultRoomReactions

@Before
fun setUp() {
every { realtimeChannels.get(any(), any()) } answers {
val channelName = firstArg<String>()
if (channelName == "room1::\$chat::\$reactions") {
realtimeChannel
} else {
buildRealtimeChannel(channelName)
}
}

roomReactions = DefaultRoomReactions(
roomId = "room1",
clientId = "client1",
realtimeChannels = realtimeChannels,
)
}
ttypic marked this conversation as resolved.
Show resolved Hide resolved

/**
* @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<PubSubMessageListener>()

every { realtimeChannel.subscribe("roomReaction", capture(pubSubMessageListenerSlot)) } returns Unit

val deferredValue = DeferredValue<Reaction>()

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,
)
}
}
1 change: 1 addition & 0 deletions example/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 54 additions & 13 deletions example/src/main/java/com/ably/chat/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,17 +73,30 @@ class MainActivity : ComponentActivity() {
}
}

@SuppressWarnings("LongMethod")
@Composable
fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) {
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var sending by remember { mutableStateOf(false) }
var messages by remember { mutableStateOf(listOf<Message>()) }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
var receivedReactions by remember { mutableStateOf<List<String>>(listOf()) }

val roomId = "my-room"
val room = chatClient.rooms.get(roomId)

DisposableEffect(Unit) {
coroutineScope.launch {
room.attach()
}
onDispose {
coroutineScope.launch {
room.detach()
}
}
}
ttypic marked this conversation as resolved.
Show resolved Hide resolved

DisposableEffect(Unit) {
val subscription = room.messages.subscribe {
messages += it.message
Expand All @@ -101,6 +115,16 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) {
}
}

DisposableEffect(Unit) {
val subscription = room.reactions.subscribe {
receivedReactions += it.type
}

onDispose {
subscription.unsubscribe()
}
}
ttypic marked this conversation as resolved.
Show resolved Hide resolved

Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
Expand All @@ -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
}
},
ttypic marked this conversation as resolved.
Show resolved Hide resolved
onReactionClick = {
coroutineScope.launch {
room.reactions.send(SendReactionParams(type = "\uD83D\uDC4D"))
}
},
ttypic marked this conversation as resolved.
Show resolved Hide resolved
)
if (receivedReactions.isNotEmpty()) {
Text("Received reactions: ${receivedReactions.joinToString()}", modifier = Modifier.padding(16.dp))
}
ttypic marked this conversation as resolved.
Show resolved Hide resolved
}
}
Expand Down Expand Up @@ -164,6 +197,7 @@ fun ChatInputField(
messageInput: TextFieldValue,
onMessageChange: (TextFieldValue) -> Unit,
onSendClick: () -> Unit,
onReactionClick: () -> Unit,
) {
Row(
modifier = Modifier
Expand All @@ -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")
}
ttypic marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down Expand Up @@ -214,6 +254,7 @@ fun ChatInputPreview() {
messageInput = TextFieldValue(""),
onMessageChange = {},
onSendClick = {},
onReactionClick = {},
)
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
ttypic marked this conversation as resolved.
Show resolved Hide resolved
kotlin = "2.0.10"
androidx-test = "1.6.1"
androidx-junit = "1.2.1"
Expand Down Expand Up @@ -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" }
Expand Down