Skip to content

Commit f519ca9

Browse files
committed
[ECO-4944] feat: add room level reaction implementation
1 parent 7aab3fb commit f519ca9

File tree

4 files changed

+170
-6
lines changed

4 files changed

+170
-6
lines changed

chat-android/src/main/java/com/ably/chat/Room.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ internal class DefaultRoom(
105105

106106
override val reactions: RoomReactions = DefaultRoomReactions(
107107
roomId = roomId,
108-
realtimeClient = realtimeClient,
108+
clientId = realtimeClient.auth.clientId,
109+
realtimeChannels = realtimeClient.channels,
109110
)
110111

111112
override val typing: Typing = DefaultTyping(

chat-android/src/main/java/com/ably/chat/RoomReactions.kt

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
package com.ably.chat
44

5+
import com.google.gson.JsonObject
6+
import io.ably.lib.realtime.AblyRealtime
57
import io.ably.lib.realtime.Channel
8+
import io.ably.lib.types.AblyException
9+
import io.ably.lib.types.ErrorInfo
10+
import io.ably.lib.types.MessageExtras
611

712
/**
813
* 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(
100105

101106
internal class DefaultRoomReactions(
102107
roomId: String,
103-
private val realtimeClient: RealtimeClient,
108+
private val clientId: String,
109+
realtimeChannels: AblyRealtime.Channels,
104110
) : RoomReactions {
111+
// (CHA-ER1)
105112
private val roomReactionsChannelName = "$roomId::\$chat::\$reactions"
106113

107-
override val channel: Channel
108-
get() = realtimeClient.channels.get(roomReactionsChannelName, ChatChannelOptions())
114+
override val channel: Channel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions())
109115

116+
// (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method.
117+
// (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format.
110118
override suspend fun send(params: SendReactionParams) {
111-
TODO("Not yet implemented")
119+
val pubSubMessage = PubSubMessage().apply {
120+
data = JsonObject().apply {
121+
addProperty("type", params.type)
122+
params.metadata?.let { add("metadata", it.toJson()) }
123+
}
124+
params.headers?.let {
125+
extras = MessageExtras(
126+
JsonObject().apply {
127+
add("headers", it.toJson())
128+
},
129+
)
130+
}
131+
}
132+
channel.publishCoroutine(pubSubMessage)
112133
}
113134

114135
override fun subscribe(listener: RoomReactions.Listener): Subscription {
115-
TODO("Not yet implemented")
136+
val messageListener = PubSubMessageListener {
137+
val pubSubMessage = it ?: throw AblyException.fromErrorInfo(
138+
ErrorInfo("Got empty pubsub channel message", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest),
139+
)
140+
val data = pubSubMessage.data as? JsonObject ?: throw AblyException.fromErrorInfo(
141+
ErrorInfo("Unrecognized Pub/Sub channel's message for `roomReaction` event", HttpStatusCodes.InternalServerError),
142+
)
143+
val reaction = Reaction(
144+
type = data.requireString("type"),
145+
createdAt = pubSubMessage.timestamp,
146+
clientId = pubSubMessage.clientId,
147+
metadata = data.get("metadata")?.toMap() ?: mapOf(),
148+
headers = pubSubMessage.extras.asJsonObject().get("headers")?.toMap() ?: mapOf(),
149+
isSelf = pubSubMessage.clientId == clientId,
150+
)
151+
listener.onReaction(reaction)
152+
}
153+
channel.subscribe(RoomReactionEventType.Reaction.eventName, messageListener)
154+
return Subscription { channel.unsubscribe(RoomReactionEventType.Reaction.eventName, messageListener) }
116155
}
117156

118157
override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription {

chat-android/src/main/java/com/ably/chat/Utils.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ suspend fun Channel.detachCoroutine() = suspendCoroutine { continuation ->
3535
})
3636
}
3737

38+
suspend fun Channel.publishCoroutine(message: PubSubMessage) = suspendCoroutine { continuation ->
39+
publish(
40+
message,
41+
object : CompletionListener {
42+
override fun onSuccess() {
43+
continuation.resume(Unit)
44+
}
45+
46+
override fun onError(reason: ErrorInfo?) {
47+
continuation.resumeWithException(AblyException.fromErrorInfo(reason))
48+
}
49+
},
50+
)
51+
}
52+
3853
@Suppress("FunctionName")
3954
fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions {
4055
val options = ChannelOptions()
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.ably.chat
2+
3+
import com.google.gson.JsonObject
4+
import io.ably.lib.realtime.AblyRealtime.Channels
5+
import io.ably.lib.realtime.Channel
6+
import io.ably.lib.realtime.buildRealtimeChannel
7+
import io.ably.lib.types.MessageExtras
8+
import io.mockk.every
9+
import io.mockk.mockk
10+
import io.mockk.slot
11+
import io.mockk.spyk
12+
import io.mockk.verify
13+
import kotlinx.coroutines.test.runTest
14+
import org.junit.Assert.assertEquals
15+
import org.junit.Before
16+
import org.junit.Test
17+
18+
class RoomReactionsTest {
19+
private val realtimeChannels = mockk<Channels>(relaxed = true)
20+
private val realtimeChannel = spyk<Channel>(buildRealtimeChannel("room1::\$chat::\$reactions"))
21+
private lateinit var roomReactions: DefaultRoomReactions
22+
23+
@Before
24+
fun setUp() {
25+
every { realtimeChannels.get(any(), any()) } answers {
26+
val channelName = firstArg<String>()
27+
if (channelName == "room1::\$chat::\$reactions") {
28+
realtimeChannel
29+
} else {
30+
buildRealtimeChannel(channelName)
31+
}
32+
}
33+
34+
roomReactions = DefaultRoomReactions(
35+
roomId = "room1",
36+
clientId = "client1",
37+
realtimeChannels = realtimeChannels,
38+
)
39+
}
40+
41+
/**
42+
* @spec CHA-ER1
43+
*/
44+
@Test
45+
fun `channel name is set according to the spec`() = runTest {
46+
val roomReactions = DefaultRoomReactions(
47+
roomId = "foo",
48+
clientId = "client1",
49+
realtimeChannels = realtimeChannels,
50+
)
51+
52+
assertEquals(
53+
"foo::\$chat::\$reactions",
54+
roomReactions.channel.name,
55+
)
56+
}
57+
58+
/**
59+
* @spec CHA-ER3a
60+
*/
61+
@Test
62+
fun `should be able to subscribe to incoming reactions`() = runTest {
63+
val pubSubMessageListenerSlot = slot<PubSubMessageListener>()
64+
65+
every { realtimeChannel.subscribe("roomReaction", capture(pubSubMessageListenerSlot)) } returns Unit
66+
67+
val deferredValue = DeferredValue<Reaction>()
68+
69+
roomReactions.subscribe {
70+
deferredValue.completeWith(it)
71+
}
72+
73+
verify { realtimeChannel.subscribe("roomReaction", any()) }
74+
75+
pubSubMessageListenerSlot.captured.onMessage(
76+
PubSubMessage().apply {
77+
data = JsonObject().apply {
78+
addProperty("type", "like")
79+
}
80+
clientId = "clientId"
81+
timestamp = 1000L
82+
extras = MessageExtras(
83+
JsonObject().apply {
84+
add(
85+
"headers",
86+
JsonObject().apply {
87+
addProperty("foo", "bar")
88+
},
89+
)
90+
},
91+
)
92+
},
93+
)
94+
95+
val reaction = deferredValue.await()
96+
97+
assertEquals(
98+
Reaction(
99+
type = "like",
100+
createdAt = 1000L,
101+
clientId = "clientId",
102+
metadata = mapOf(),
103+
headers = mapOf("foo" to "bar"),
104+
isSelf = false,
105+
),
106+
reaction,
107+
)
108+
}
109+
}

0 commit comments

Comments
 (0)