Skip to content

Commit 8b3b076

Browse files
committed
WIP: basic chat implementation
1 parent 0ee8e80 commit 8b3b076

File tree

6 files changed

+111
-38
lines changed

6 files changed

+111
-38
lines changed

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,20 @@ private const val API_PROTOCOL_VERSION = 3
1717
private const val PROTOCOL_VERSION_PARAM_NAME = "v"
1818
private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString())
1919

20-
// TODO make this class internal
21-
class ChatApi(private val realtimeClient: RealtimeClient, private val clientId: String) {
20+
internal class ChatApi(private val realtimeClient: RealtimeClient, private val clientId: String) {
2221

2322
/**
2423
* Get messages from the Chat Backend
2524
*
2625
* @return paginated result with messages
2726
*/
28-
suspend fun getMessages(roomId: String, params: QueryOptions): PaginatedResult<Message> {
27+
suspend fun getMessages(roomId: String, options: QueryOptions, fromSerial: String? = null): PaginatedResult<Message> {
28+
val baseParams = options.toParams()
29+
val params = fromSerial?.let { baseParams + Param("fromSerial", it) } ?: baseParams
2930
return makeAuthorizedPaginatedRequest(
3031
url = "/chat/v1/rooms/$roomId/messages",
3132
method = "GET",
32-
params = params.toParams(),
33+
params = params,
3334
) {
3435
Message(
3536
timeserial = it.requireString("timeserial"),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ interface ChatClient {
3737
val clientOptions: ClientOptions
3838
}
3939

40-
fun ChatClient(realtimeClient: RealtimeClient, clientOptions: ClientOptions): ChatClient = DefaultChatClient(realtimeClient, clientOptions)
40+
fun ChatClient(realtimeClient: RealtimeClient, clientOptions: ClientOptions = ClientOptions()): ChatClient =
41+
DefaultChatClient(realtimeClient, clientOptions)
4142

4243
internal class DefaultChatClient(
4344
override val realtime: RealtimeClient,

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

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
package com.ably.chat
44

5+
import com.ably.chat.QueryOptions.MessageOrder.NewestFirst
6+
import com.google.gson.JsonObject
57
import io.ably.lib.realtime.Channel
8+
import io.ably.lib.realtime.Channel.MessageListener
9+
import io.ably.lib.realtime.ChannelState
10+
import kotlin.coroutines.resume
11+
import kotlin.coroutines.suspendCoroutine
612

713
/**
814
* This interface is used to interact with messages in a chat room: subscribing
@@ -91,7 +97,7 @@ data class QueryOptions(
9197
/**
9298
* The order of messages in the query result.
9399
*/
94-
val orderBy: MessageOrder = MessageOrder.NewestFirst,
100+
val orderBy: MessageOrder = NewestFirst,
95101
) {
96102
/**
97103
* Represents direction to query messages in.
@@ -169,26 +175,73 @@ data class SendMessageParams(
169175
val headers: MessageHeaders? = null,
170176
)
171177

172-
interface MessagesSubscription: Cancellation {
178+
interface MessagesSubscription : Cancellation {
173179
suspend fun getPreviousMessages(queryOptions: QueryOptions): PaginatedResult<Message>
174180
}
175181

176-
class DefaultMessages(
182+
internal class DefaultMessagesSubscription(
183+
private val chatApi: ChatApi,
184+
private val roomId: String,
185+
private val cancellation: Cancellation,
186+
private val timeserialProvider: suspend () -> String,
187+
) : MessagesSubscription {
188+
override fun cancel() {
189+
cancellation.cancel()
190+
}
191+
192+
override suspend fun getPreviousMessages(queryOptions: QueryOptions): PaginatedResult<Message> {
193+
val fromSerial = timeserialProvider()
194+
return chatApi.getMessages(
195+
roomId = roomId,
196+
options = queryOptions.copy(orderBy = NewestFirst),
197+
fromSerial = fromSerial,
198+
)
199+
}
200+
}
201+
202+
internal class DefaultMessages(
177203
private val roomId: String,
178-
private val realtimeClient: RealtimeClient,
204+
realtimeClient: RealtimeClient,
179205
private val chatApi: ChatApi,
180206
) : Messages {
181207

208+
private var observers: Set<Messages.Listener> = emptySet()
209+
210+
private var channelSerial: String? = null
211+
182212
/**
183213
* the channel name for the chat messages channel.
184214
*/
185215
private val messagesChannelName = "$roomId::\$chat::\$chatMessages"
186216

187-
override val channel: Channel
188-
get() = realtimeClient.channels.get(messagesChannelName, ChatChannelOptions())
217+
override val channel: Channel = realtimeClient.channels.get(messagesChannelName, ChatChannelOptions())
189218

190219
override fun subscribe(listener: Messages.Listener): MessagesSubscription {
191-
TODO("Not yet implemented")
220+
observers += listener
221+
val messageListener = MessageListener {
222+
val pubSubMessage = it!!
223+
val chatMessage = Message(
224+
roomId = roomId,
225+
createdAt = pubSubMessage.timestamp,
226+
clientId = pubSubMessage.clientId,
227+
timeserial = pubSubMessage.extras.asJsonObject().get("timeserial").asString,
228+
text = (pubSubMessage.data as JsonObject).get("text").asString,
229+
metadata = mapOf(), // rawPubSubMessage.data.metadata
230+
headers = mapOf(), // rawPubSubMessage.extras.headers
231+
)
232+
observers.forEach { listener -> listener.onEvent(MessageEvent(type = MessageEventType.Created, message = chatMessage)) }
233+
}
234+
channel.subscribe(messageListener)
235+
236+
return DefaultMessagesSubscription(
237+
chatApi = chatApi,
238+
roomId = roomId,
239+
cancellation = {
240+
observers -= listener
241+
channel.unsubscribe(messageListener)
242+
},
243+
timeserialProvider = { getChannelSerial() },
244+
)
192245
}
193246

194247
override suspend fun get(options: QueryOptions): PaginatedResult<Message> = chatApi.getMessages(roomId, options)
@@ -198,4 +251,16 @@ class DefaultMessages(
198251
override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Cancellation {
199252
TODO("Not yet implemented")
200253
}
254+
255+
private suspend fun readAttachmentProperties() = suspendCoroutine { continuation ->
256+
channel.once(ChannelState.attached) {
257+
continuation.resume(channel.properties)
258+
}
259+
}
260+
261+
private suspend fun getChannelSerial(): String {
262+
if (channelSerial != null) return channelSerial!!
263+
channelSerial = readAttachmentProperties().channelSerial
264+
return channelSerial!!
265+
}
201266
}

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

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package com.ably.chat
22

3-
import io.ably.lib.types.AblyException
4-
import io.ably.lib.types.ErrorInfo
5-
63
/**
74
* Manages the lifecycle of chat rooms.
85
*/
@@ -24,7 +21,7 @@ interface Rooms {
2421
* @throws {@link ErrorInfo} if a room with the same ID but different options already exists.
2522
* @returns Room A new or existing Room object.
2623
*/
27-
fun get(roomId: String, options: RoomOptions): Room
24+
fun get(roomId: String, options: RoomOptions = RoomOptions()): Room
2825

2926
/**
3027
* Release the Room object if it exists. This method only releases the reference
@@ -60,11 +57,11 @@ internal class DefaultRooms(
6057
)
6158
}
6259

63-
if (room.options != options) {
64-
throw AblyException.fromErrorInfo(
65-
ErrorInfo("Room already exists with different options", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest),
66-
)
67-
}
60+
// if (room.options != options) {
61+
// throw AblyException.fromErrorInfo(
62+
// ErrorInfo("Room already exists with different options", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest),
63+
// )
64+
// }
6865

6966
room
7067
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ suspend fun Channel.detachCoroutine() = suspendCoroutine { continuation ->
3939
fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions {
4040
val options = ChannelOptions()
4141
init?.let { options.it() }
42-
options.params = options.params + mapOf(
42+
options.params = (options.params ?: mapOf()) + mapOf(
4343
AGENT_PARAMETER_NAME to "chat-kotlin/${BuildConfig.APP_VERSION}",
4444
)
4545
return options

example/src/main/java/com/ably/chat/example/MainActivity.kt

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.material3.Scaffold
2020
import androidx.compose.material3.Text
2121
import androidx.compose.material3.TextField
2222
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.DisposableEffect
2324
import androidx.compose.runtime.getValue
2425
import androidx.compose.runtime.mutableStateOf
2526
import androidx.compose.runtime.remember
@@ -30,10 +31,9 @@ import androidx.compose.ui.Modifier
3031
import androidx.compose.ui.graphics.Color
3132
import androidx.compose.ui.text.input.TextFieldValue
3233
import androidx.compose.ui.unit.dp
33-
import com.ably.chat.ChatApi
34+
import com.ably.chat.ChatClient
3435
import com.ably.chat.Message
3536
import com.ably.chat.QueryOptions
36-
import com.ably.chat.QueryOptions.MessageOrder.OldestFirst
3737
import com.ably.chat.RealtimeClient
3838
import com.ably.chat.SendMessageParams
3939
import com.ably.chat.example.ui.theme.AblyChatExampleTheme
@@ -46,20 +46,23 @@ val randomClientId = UUID.randomUUID().toString()
4646
class MainActivity : ComponentActivity() {
4747
override fun onCreate(savedInstanceState: Bundle?) {
4848
super.onCreate(savedInstanceState)
49+
4950
val realtimeClient = RealtimeClient(
5051
ClientOptions().apply {
5152
key = BuildConfig.ABLY_KEY
5253
clientId = randomClientId
5354
logLevel = 2
5455
},
5556
)
56-
val chatApi = ChatApi(realtimeClient, randomClientId)
57+
58+
val chatClient = ChatClient(realtimeClient)
59+
5760
enableEdgeToEdge()
5861
setContent {
5962
AblyChatExampleTheme {
6063
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
6164
Chat(
62-
chatApi,
65+
chatClient,
6366
modifier = Modifier.padding(innerPadding),
6467
)
6568
}
@@ -69,26 +72,33 @@ class MainActivity : ComponentActivity() {
6972
}
7073

7174
@Composable
72-
fun Chat(chatApi: ChatApi, modifier: Modifier = Modifier) {
75+
fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) {
7376
var messageText by remember { mutableStateOf(TextFieldValue("")) }
7477
var sending by remember { mutableStateOf(false) }
7578
var messages by remember { mutableStateOf(listOf<Message>()) }
7679
val coroutineScope = rememberCoroutineScope()
7780

7881
val roomId = "my-room"
82+
val room = chatClient.rooms.get(roomId)
83+
84+
DisposableEffect(Unit) {
85+
val subscription = room.messages.subscribe {
86+
messages += it.message
87+
}
88+
89+
coroutineScope.launch {
90+
messages = subscription.getPreviousMessages(QueryOptions()).items
91+
}
92+
93+
onDispose {
94+
subscription.cancel()
95+
}
96+
}
7997

8098
Column(
81-
modifier = Modifier.fillMaxSize(),
99+
modifier = modifier.fillMaxSize(),
82100
verticalArrangement = Arrangement.SpaceBetween,
83101
) {
84-
Button(modifier = modifier.align(Alignment.CenterHorizontally), onClick = {
85-
coroutineScope.launch {
86-
messages = chatApi.getMessages(roomId, QueryOptions(orderBy = OldestFirst)).items
87-
}
88-
}) {
89-
Text("Load")
90-
}
91-
92102
LazyColumn(
93103
modifier = Modifier.weight(1f).padding(16.dp),
94104
userScrollEnabled = true,
@@ -105,8 +115,7 @@ fun Chat(chatApi: ChatApi, modifier: Modifier = Modifier) {
105115
) {
106116
sending = true
107117
coroutineScope.launch {
108-
chatApi.sendMessage(
109-
roomId,
118+
room.messages.send(
110119
SendMessageParams(
111120
text = messageText.text,
112121
),

0 commit comments

Comments
 (0)