From 678b1cb51bbd3a6eb9c65c9fa05338291ff4b377 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 21 May 2025 12:42:26 +0100 Subject: [PATCH 1/5] Add Jetpack Compose guide for Ably Chat integration. This guide outlines setup and usage of Ably Chat in an Android app built with Jetpack Compose. It covers key features such as realtime messaging, presence, reactions, message history, and resource management. --- .../docs/chat/getting-started/kotlin.textile | 995 ++++++++++++++++++ 1 file changed, 995 insertions(+) create mode 100644 src/pages/docs/chat/getting-started/kotlin.textile diff --git a/src/pages/docs/chat/getting-started/kotlin.textile b/src/pages/docs/chat/getting-started/kotlin.textile new file mode 100644 index 0000000000..dc1b1b1831 --- /dev/null +++ b/src/pages/docs/chat/getting-started/kotlin.textile @@ -0,0 +1,995 @@ +--- +title: "Getting started: Chat with Jetpack Compose" +meta_description: "A getting started guide for Ably Chat Android that steps through some of the key features using Jetpack Compose." +meta_keywords: "Ably, realtime, quickstart, getting started, basics, Chat, Android, Kotlin, Jetpack Compose" +languages: + - kotlin +--- + +This guide will help you get started with Ably Chat in a new Android application +built with Jetpack Compose. + +It will take you through the following steps: + +* Creating a client and establishing a realtime connection to Ably +* Creating a room and subscribing to its messages +* Sending messages to the room and editing messages +* Retrieving historical messages to provide context for new joiners +* Displaying online status of clients in the room +* Subscribing to and sending reactions +* Disconnecting and resource cleanup + +h2(#prerequisites). Prerequisites + +h3(#prerequisites-ably). Ably +* Sign up for an Ably account +* Create a new app and get your API key +** You can use the root API key that is provided by default to get started. + +* Install the Ably CLI: + +```[sh] +npm install -g @ably/cli +``` + +* Run the following to log in to your Ably account and set the default app and API key: + +```[sh] +ably login + +ably apps switch +ably auth keys switch +``` + + + + +h3(#prerequisites-create-project). Create a new project + +Create a new Android project with Jetpack Compose. For detailed instructions, refer to the "Android Studio documentation":https://developer.android.com/jetpack/compose/setup. + +* Create a new Android project in Android Studio +* Select "Empty Activity" as the template +* Name project "Chat Example" and place it in the @com.example.chatexample@ package +* Set minimum SDK level to API 24 or higher +* Use Kotlin as the programming language +* Add the Ably dependencies to your app-level @build.gradle.kts@ file: + +```[kotlin] +implementation("com.ably.chat:chat-android:") +// This package contains extension functions for better Jetpack Compose integration. +// It’s experimental for now (safe to use, but the API may change later). You can always use its code as a reference. +implementation("com.ably.chat:chat-extensions-compose:") +``` + +h2(#providers-setup). Step 1: Setting up the Ably + +* Replace the content of your @MainActivity.kt@ file with the following code to set up Ably client: + +```[kotlin] +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Colum +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import java.util.UUID +import com.example.chatexample.ui.theme.ChatExampleTheme +import com.ably.chat.* +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.types.ClientOptions + +class MainActivity : ComponentActivity() { + private lateinit var realtimeClient: AblyRealtime + private lateinit var chatClient: ChatClient + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + realtimeClient = AblyRealtime( + ClientOptions().apply { + key = "{{API_KEY}}" + clientId = "my-first-client" + }, + ) + + chatClient = ChatClient(realtimeClient) { logLevel = LogLevel.Info } + + enableEdgeToEdge() + setContent { + ChatExampleTheme { + App(chatClient) + } + } + } +} + +@Composable +fun App(chatClient: ChatClient) { + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Text("Hello Chat App") + } + } +} +``` + +h2(#step-1). Step 2: Connect to Ably + +Clients establish a connection with Ably when they instantiate an SDK. This enables them to send and receive messages in realtime across channels. + +* In your @MainActivity.kt@ file, add the following @ConnectionStatusUi@ composable component: + +```[kotlin] +// This component will display the current connection status +@Composable +fun ConnectionStatusUi(connection: Connection) { + val connectionStatus = connection.collectAsStatus() + Text( + text = "Connection Status: ${connectionStatus}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) +} +``` + +* Then, update the @App@ component to display the connection status using the new @ConnectionStatusUi@ component: + +```[kotlin] +@Composable +fun App(chatClient: ChatClient) { + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ConnectionStatusUi(connection = chatClient.connection) + } + } +} +``` + +Now run your application by pressing Run button. + +h2(#step-2). Step 3: Create a room + +Now that you have a connection to Ably, you can create a room. Use rooms to separate and organize clients and messages into different topics, or 'chat rooms'. Rooms are the entry point for Chat, providing access to all of its features, such as messages, presence and reactions. + +* In your project, open @MainActivity.kt@, and add a new component called @RoomStatusUi@: + +```[kotlin] +@Composable +fun RoomStatusUi(roomName: String, room: Room?) { + val roomStatus = room?.collectAsStatus() + Text( + text = "Room Name: ${roomName}, Room Status: ${roomStatus ?: ""}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) +} +``` + +* Then, update your main app component to get and attach to the room and nest the @RoomStatusUi@ component inside it: + +```[kotlin] +@Composable +fun App(chatClient: ChatClient) { + val connectionStatus = chatClient.connection.collectAsStatus() + val roomName = "my-first-room" + var room by remember { mutableStateOf(null) } + LaunchedEffect(roomName) { + val chatRoom = chatClient.rooms.get(roomName) + chatRoom.attach() + room = chatRoom + } + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ConnectionStatusUi(connection = chatClient.connection) + RoomStatusUi(roomName = roomName, room = room) + } + } +} +``` + +The above code creates a room with the ID @my-first-room@ and sets up a listener to monitor the room status. It also displays the room ID and current status in the UI. + + + +h2(#step-3). Step 4: Send a message + +Messages are how your clients interact with one another. + +* In your project, open @MainActivity.kt@, and add a new component called @ChatBox@, like so: + +```[kotlin] +@Composable +fun ChatBox(room: Room?) { + val scope = rememberCoroutineScope() + var textInput by remember { mutableStateOf(TextFieldValue("")) } + var sending by remember { mutableStateOf(false) } + val messages = remember { mutableStateListOf() } + + DisposableEffect(room) { + val subscription = room?.messages?.subscribe { event -> + when (event.type) { + MessageEventType.Created -> messages.add(0, event.message) + } + } + + onDispose { + subscription?.unsubscribe() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Card( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + if (messages.isNullOrEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No messages yet", + color = Color.Gray + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + reverseLayout = true, + ) { + items(messages.size, key = { messages[it].serial }) { + val message = messages[it] + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text( + text = "${message.clientId}: ${message.text}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + .format(Date(message.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + modifier = Modifier.align(Alignment.End) + ) + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Message input + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = textInput, + onValueChange = { textInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a message") }, + maxLines = 3 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + sending = true + if (textInput.text.isNotBlank()) { + scope.launch { + try { + room?.messages?.send(textInput.text) + } catch (e: Exception) { + Log.e("APP", e.message, e) + } + textInput = TextFieldValue("") + sending = false + } + } + }, + enabled = textInput.text.isNotBlank() && !sending + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) + } + } + } +} +``` + +* Next, add the @ChatBox@ component to your main app component: + +```[kotlin] +@Composable +fun App(chatClient: ChatClient) { + val roomName = "my-first-room" + var room by remember { mutableStateOf(null) } + LaunchedEffect(roomName) { + val chatRoom = chatClient.rooms.get( + roomName, + ) + chatRoom.attach() + room = chatRoom + } + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ConnectionStatusUi(connection = chatClient.connection) + RoomStatusUi(roomName = roomName, room = room) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + ChatBox(room = room) + } + } +} +``` + +The UI should automatically render the new component, and you should be able to send messages to the room. + +* Type a message in the input box and click the send button. You'll see the message appear in the chat box. + +You can also use the Ably CLI to send a message to the room from another environment: + +```[sh] + ably rooms messages send my-first-room 'Hello from CLI!' +``` + +You'll see the message in your app's chat box UI. If you have sent a message via CLI, it should appear in a different color to the one you sent from the app. + +h2(#step-4). Step 5: Edit a message + +If your client makes a typo, or needs to update their original message then they can edit it. To do this, you can extend the functionality of the @ChatBox@ component to allow updating of messages. + +Add edited state variable that will contain edited now message or null if message is not edited: + +```[kotlin] +var edited: Message? by remember { mutableStateOf(null) } +``` +Update Send button code + +```[kotlin] +Button( + onClick = { + sending = true + if (textInput.text.isNotBlank()) { + scope.launch { + try { + edited?.let { + room?.messages?.update(it.copy(text = textInput.text)) + } ?: room?.messages?.send(textInput.text) + } catch (e: Exception) { + Log.e("APP", e.message, e) + } + edited = null + textInput = TextFieldValue("") + sending = false + } + } + }, + enabled = textInput.text.isNotBlank() && !sending +) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = if (edited != null) "Edit" else "Send" + ) +} +``` + +* Update the rendering of messages in the chat box to enable the update action in the UI: + +```[kotlin] +Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) +) { + Card( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + if (messages.isNullOrEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No messages yet", + color = Color.Gray + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + state = messagesState.listState + ) { + items(messages.size, key = { messages[it].serial }) { + val message = messages[it] + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp) + ) { + Text( + text = "${message.clientId}: ${message.text}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + .format(Date(message.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + modifier = Modifier.align(Alignment.End) + ) + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + } + Box { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More Options") + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + expanded = false + edited = message + textInput = TextFieldValue(message.text) + }, + ) + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Message input + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = textInput, + onValueChange = { textInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a message") }, + maxLines = 3 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + sending = true + if (textInput.text.isNotBlank()) { + scope.launch { + try { + edited?.let { + room?.messages?.update(it.copy(text = textInput.text)) + } ?: room?.messages?.send(textInput.text) + } catch (e: Exception) { + Log.e("APP", e.message, e) + } + textInput = TextFieldValue("") + sending = false + } + } + }, + enabled = textInput.text.isNotBlank() && !sending + ) { + Icon( + imageVector = if (edited != null) Icons.Default.Edit + else Icons.AutoMirrored.Filled.Send, + contentDescription = if (edited != null) "Edit" else "Send" + ) + } + } +} +``` + +* Next, you must update the listener provided to the @DisposableEffect@ to handle the @MessageEventType.Updated@ event: + +```[kotlin] +DisposableEffect(room) { + val subscription = room?.messages?.subscribe { event -> + when (event.type) { + MessageEventType.Created -> messages.add(0, event.message) + MessageEventType.Updated -> messages.replaceFirstWith(event.message) { + it.serial == event.message.serial + } + else -> Unit + } + } + + onDispose { + subscription?.unsubscribe() + } +} +``` + +* @replaceFirstWith@ function can be implemented: + +```[kotlin] +inline fun MutableList.replaceFirstWith(replacement: T, predicate: (T) -> Boolean) { + val index = indexOfFirst(predicate) + if (index != -1) set(index, replacement) +} +``` + +* Now, when you click on the edit button, modify text and resend it. + +h2(#step-5). Step 6: Message history and continuity + +Ably Chat enables you to retrieve previously sent messages in a room. This is useful for providing conversational context when a user first joins a room, or when they subsequently rejoin it later on. The message subscription object exposes the "getPreviousMessages()":https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/-messages-subscription/get-previous-messages.html method to enable this functionality. This method returns a paginated response, which can be queried further to retrieve the next set of messages. +To do this, you need to extend the @ChatBox@ component to include a method to retrieve the last 10 messages when the component mounts. + +* In your @MainActivity.kt@ file, add the @DisposableEffect@ in your @ChatBox@ component: + +```[kotlin] +fun ChatBox(room: Room?) { + val componentScope = rememberCoroutineScope() + DisposableEffect(room) { + val subscription = room?.messages?.subscribe { event -> + when (event.type) { + MessageEventType.Created -> messages.add(0, event.message) + MessageEventType.Updated -> messages.replaceFirstWith(event.message) { + it.serial == event.message.serial + } + else -> Unit + } + } + + componentScope.launch { + val previousMessages = subscription?.getPreviousMessages(10) ?: emptyList() + messages += previousMessages + } + + onDispose { + subscription?.unsubscribe() + } + } + /* rest of your code */ +} +``` + +The above code will retrieve the last 10 messages when the component mounts, and set them in the state. + +You also can use @collectAsPagingMessagesState@ to automatically subscribe to new messages +and lazily load previous messages as you scroll through the message list. + +```[kotlin] +@Composable +fun ChatBox(room: Room?, onSendMessage: suspend (String) -> Unit) { + val scope = rememberCoroutineScope() + var textInput by remember { mutableStateOf(TextFieldValue("")) } + val messagesState = room?.collectAsPagingMessagesState() + var sending by remember { mutableStateOf(false) } + var edited by remember { mutableStateOf(null) } + val messages = messagesState?.loaded + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Card( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + if (messages.isNullOrEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No messages yet", + color = Color.Gray + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + state = messagesState.listState + ) { + items(messages.size, key = { messages[it].serial }) { + val message = messages[it] + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp) + ) { + Text( + text = "${message.clientId}: ${message.text}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + .format(Date(message.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + modifier = Modifier.align(Alignment.End) + ) + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + } + IconButton(onClick = { + edited = message + textInput = TextFieldValue(message.text) + }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Message input + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = textInput, + onValueChange = { textInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a message") }, + maxLines = 3 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + sending = true + if (textInput.text.isNotBlank()) { + scope.launch { + try { + edited?.let { + room?.messages?.update(it.copy(text = textInput.text)) + } ?: room?.messages?.send(textInput.text) + } catch (e: Exception) { + Log.e("APP", e.message, e) + } + edited = null + textInput = TextFieldValue("") + sending = false + } + } + }, + enabled = textInput.text.isNotBlank() && !sending + ) { + Icon( + imageVector = if (edited != null) Icons.Default.Edit + else Icons.AutoMirrored.Filled.Send, + contentDescription = if (edited != null) "Edit" else "Send" + ) + } + } + } +} +``` + +Try the following to test this feature: + +1. Use the ably CLI to simulate sending some messages to the room from another client. +2. Refresh the page, this will cause the @ChatBox@ component to mount again and call the @getPreviousMessages()@ method. +3. You should see the last 10 messages appear in the chat box. + +h2(#step-6). Step 7: Display who is present in the room + +Display the online status of clients using the presence feature. This enables clients to be aware of one another if they are present in the same room. You can then show clients who else is online, provide a custom status update for each, and notify the room when someone enters it, or leaves it, such as by going offline. + +* In your @MainActivity.kt@ file, create a new component called @PresenceStatusUi@ like so: + +```[kotlin] +@Composable +fun PresenceStatusUi(room: Room?) { + val members = room?.collectAsPresenceMembers() + + LaunchedEffect(room) { + room?.presence?.enter() + } + + Text( + text = "Online: ${members?.size ?: 0}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) +} +``` + +* Then add the @PresenceStatusUi@ component to your main app component like so: + +```[kotlin] +@Composable +fun App(chatClient: ChatClient) { + val roomName = "my-first-room" + var room by remember { mutableStateOf(null) } + LaunchedEffect(roomName) { + val chatRoom = chatClient.rooms.get( + roomName, + ) + chatRoom.attach() + room = chatRoom + } + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ConnectionStatusUi(connection = chatClient.connection) + RoomStatusUi(roomName = roomName, room = room) + PresenceStatusUi(room = room) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + ChatBox(room = room) + } + } +} +``` + +You'll now see your current client ID in the list of present users. + +* You can also use the Ably CLI to enter the room from another client by running the following command: + +```[sh] + ably rooms presence enter my-first-room --client-id "my-cli" +``` + +h2(#step-7). Step 8: Send a reaction + +Clients can send a reaction to a room to show their sentiment for what is happening, such as a point being scored in a sports game. +These are short-lived (ephemeral) and are not stored in the room history. + +* In your @MainActivity.kt@ file, add a new component called @ReactionBar@, like so: + +```[react] +@Composable +fun ReactionBar(room: Room?) { + val scope = rememberCoroutineScope() + val availableReactions = listOf("πŸ‘", "❀️", "πŸ’₯", "πŸš€", "πŸ‘Ž", "πŸ’”") + val receivedReactions = remember { mutableStateListOf() } + + LaunchedEffect(room) { + room?.reactions?.asFlow()?.collect { + receivedReactions.add(it) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Reaction send buttons + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(availableReactions) { emoji -> + Button( + onClick = { + scope.launch { + room?.reactions?.send(emoji) + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color.LightGray) + ) { + Text( + text = emoji, + fontSize = 12.sp + ) + } + } + } + + // Display received reactions + if (receivedReactions.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Received Reactions", + modifier = Modifier.padding(vertical = 2.dp) + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(receivedReactions) { reaction -> + Box( + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = reaction.type, + fontSize = 12.sp + ) + } + } + } + } + } + } +} +``` + +* Next, add the @ReactionBar@ component to your main app component: + +```[kotlin] +@Composable +fun App(chatClient: ChatClient) { + val roomName = "my-first-room" + var room by remember { mutableStateOf(null) } + LaunchedEffect(roomName) { + val chatRoom = chatClient.rooms.get( + roomName, + ) + chatRoom.attach() + room = chatRoom + } + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ConnectionStatusUi(connection = chatClient.connection) + RoomStatusUi(roomName = roomName, room = room) + PresenceStatusUi(room = room) + ReactionBar(room = room) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + ChatBox(room = room) + } + } +} +``` + +The above code should display a list of reactions that can be sent to the room. When you click on a reaction, it will send it to the room and display it in the UI. + +* You can also send a reaction to the room via the Ably CLI by running the following command: + +```[sh] + ably rooms reactions send my-first-room πŸ‘ +``` + +h2(#step-8). Step 9: Disconnection and clean up resources + +To gracefully close connection and clean up resources, you can subscribe to activity lifecycle events and close connection +when activity on pause or stop, and reconnect when activity is restarts. To do this modify @MainActivity@ class like this: + +```[kotlin] +class MainActivity : ComponentActivity() { + private lateinit var realtimeClient: AblyRealtime + private lateinit var chatClient: ChatClient + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + realtimeClient = AblyRealtime( + ClientOptions().apply { + key = "{{API_KEY}}" + clientId = "my-first-client" + }, + ) + + chatClient = ChatClient(realtimeClient) { logLevel = LogLevel.Info } + + enableEdgeToEdge() + setContent { + ChatExampleTheme { + App(chatClient) + } + } + } + + override fun onRestart() { + super.onRestart() + realtimeClient.connect() + } + + override fun onResume() { + super.onResume() + realtimeClient.connect() + } + + override fun onPause() { + super.onPause() + realtimeClient.close() + } + + override fun onStop() { + super.onStop() + realtimeClient.close() + } +} +``` + +h2(#next). Next steps + +Continue exploring Ably Chat with Kotlin: + +Read more about the concepts covered in this guide: +* Read more about using "rooms":/docs/chat/rooms and sending "messages":/docs/chat/rooms/messages?lang=react. +* Find out more regarding "online status":/docs/chat/rooms/presence?lang=kotlin. +* Understand how to use "typing indicators":/docs/chat/rooms/typing?lang=kotlin. +* Send "reactions":/docs/chat/rooms/reactions?lang=kotlin to your rooms. +* Read into pulling messages from "history":/docs/chat/rooms/history?lang=kotlin and providing context to new joiners. +* Understand "token authentication":/docs/authentication/token-authentication before going to production. + +Explore the Ably CLI further, +or check out the "Chat Kotlin API references":https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/ for additional functionality. From f42cc05d4bf902413f3eb7823bc55ed981f20763 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 22 May 2025 10:18:34 +0100 Subject: [PATCH 2/5] chat: add kotlin getting started to nav --- src/data/nav/chat.ts | 4 + .../{kotlin.textile => kotlin.mdx} | 255 ++++++++++-------- 2 files changed, 154 insertions(+), 105 deletions(-) rename src/pages/docs/chat/getting-started/{kotlin.textile => kotlin.mdx} (81%) diff --git a/src/data/nav/chat.ts b/src/data/nav/chat.ts index 7b7ef6b900..390d725d24 100644 --- a/src/data/nav/chat.ts +++ b/src/data/nav/chat.ts @@ -32,6 +32,10 @@ export default { name: 'React', link: '/docs/chat/getting-started/react', }, + { + name: 'Kotlin', + link: '/docs/chat/getting-started/kotlin', + }, ], }, { diff --git a/src/pages/docs/chat/getting-started/kotlin.textile b/src/pages/docs/chat/getting-started/kotlin.mdx similarity index 81% rename from src/pages/docs/chat/getting-started/kotlin.textile rename to src/pages/docs/chat/getting-started/kotlin.mdx index dc1b1b1831..9417311dc8 100644 --- a/src/pages/docs/chat/getting-started/kotlin.textile +++ b/src/pages/docs/chat/getting-started/kotlin.mdx @@ -1,12 +1,10 @@ --- -title: "Getting started: Chat with Jetpack Compose" +title: "Getting started: Chat with Kotlin" meta_description: "A getting started guide for Ably Chat Android that steps through some of the key features using Jetpack Compose." meta_keywords: "Ably, realtime, quickstart, getting started, basics, Chat, Android, Kotlin, Jetpack Compose" -languages: - - kotlin --- -This guide will help you get started with Ably Chat in a new Android application +This guide will help you get started with Ably Chat in a new Android Kotlin application built with Jetpack Compose. It will take you through the following steps: @@ -19,56 +17,62 @@ It will take you through the following steps: * Subscribing to and sending reactions * Disconnecting and resource cleanup -h2(#prerequisites). Prerequisites +## Prerequisites -h3(#prerequisites-ably). Ably -* Sign up for an Ably account -* Create a new app and get your API key -** You can use the root API key that is provided by default to get started. +### Ably -* Install the Ably CLI: +1. Sign up for an Ably account. +2. Create a new app and get your API key. You can use the root API key that is provided by default to get started. -```[sh] +3. Install the Ably CLI: + + +```shell npm install -g @ably/cli ``` + -* Run the following to log in to your Ably account and set the default app and API key: +4. Run the following to log in to your Ably account and set the default app and API key: -```[sh] + +```shell ably login ably apps switch ably auth keys switch ``` + - - + -h3(#prerequisites-create-project). Create a new project +### Create a new project -Create a new Android project with Jetpack Compose. For detailed instructions, refer to the "Android Studio documentation":https://developer.android.com/jetpack/compose/setup. +Create a new Android project with Jetpack Compose. For detailed instructions, refer to the [Android Studio documentation](https://developer.android.com/jetpack/compose/setup). -* Create a new Android project in Android Studio -* Select "Empty Activity" as the template -* Name project "Chat Example" and place it in the @com.example.chatexample@ package -* Set minimum SDK level to API 24 or higher -* Use Kotlin as the programming language -* Add the Ably dependencies to your app-level @build.gradle.kts@ file: +1. Create a new Android project in Android Studio. +2. Select **Empty Activity** as the template +3. Name the project **Chat Example** and place it in the `com.example.chatexample` package +4. Set the minimum SDK level to API 24 or higher +5. Select Kotlin as the programming language +6. Add the Ably dependencies to your app-level `build.gradle.kts` file: -```[kotlin] + +```kotlin implementation("com.ably.chat:chat-android:") // This package contains extension functions for better Jetpack Compose integration. -// It’s experimental for now (safe to use, but the API may change later). You can always use its code as a reference. +// It's experimental for now (safe to use, but the API may change later). You can always use its code as a reference. implementation("com.ably.chat:chat-extensions-compose:") ``` + -h2(#providers-setup). Step 1: Setting up the Ably +## Step 1: Setting up Ably -* Replace the content of your @MainActivity.kt@ file with the following code to set up Ably client: +Replace the contents of your `MainActivity.kt` file with the following code to set up the Ably client: -```[kotlin] + +```kotlin import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -125,14 +129,16 @@ fun App(chatClient: ChatClient) { } } ``` + -h2(#step-1). Step 2: Connect to Ably +## Step 2: Connect to Ably Clients establish a connection with Ably when they instantiate an SDK. This enables them to send and receive messages in realtime across channels. -* In your @MainActivity.kt@ file, add the following @ConnectionStatusUi@ composable component: +In your `MainActivity.kt` file, add the following `ConnectionStatusUi` composable component: -```[kotlin] + +```kotlin // This component will display the current connection status @Composable fun ConnectionStatusUi(connection: Connection) { @@ -144,10 +150,12 @@ fun ConnectionStatusUi(connection: Connection) { ) } ``` + -* Then, update the @App@ component to display the connection status using the new @ConnectionStatusUi@ component: +Update the `App` component to display the connection status using the new `ConnectionStatusUi` component: -```[kotlin] + +```kotlin @Composable fun App(chatClient: ChatClient) { Scaffold { paddingValues -> @@ -161,16 +169,18 @@ fun App(chatClient: ChatClient) { } } ``` + -Now run your application by pressing Run button. +Run your application by pressing **Run** button. -h2(#step-2). Step 3: Create a room +## Step 3: Create a room Now that you have a connection to Ably, you can create a room. Use rooms to separate and organize clients and messages into different topics, or 'chat rooms'. Rooms are the entry point for Chat, providing access to all of its features, such as messages, presence and reactions. -* In your project, open @MainActivity.kt@, and add a new component called @RoomStatusUi@: +In your project, open `MainActivity.kt`, and add a new component called `RoomStatusUi`: -```[kotlin] + +```kotlin @Composable fun RoomStatusUi(roomName: String, room: Room?) { val roomStatus = room?.collectAsStatus() @@ -181,10 +191,12 @@ fun RoomStatusUi(roomName: String, room: Room?) { ) } ``` + -* Then, update your main app component to get and attach to the room and nest the @RoomStatusUi@ component inside it: +Update your main app component to get and attach to the room and nest the `RoomStatusUi` component inside it: -```[kotlin] + +```kotlin @Composable fun App(chatClient: ChatClient) { val connectionStatus = chatClient.connection.collectAsStatus() @@ -207,20 +219,22 @@ fun App(chatClient: ChatClient) { } } ``` + -The above code creates a room with the ID @my-first-room@ and sets up a listener to monitor the room status. It also displays the room ID and current status in the UI. +The above code creates a room with the ID `my-first-room` and sets up a listener to monitor the room status. It also displays the room ID and current status in the UI. - + -h2(#step-3). Step 4: Send a message +## Step 4: Send a message Messages are how your clients interact with one another. -* In your project, open @MainActivity.kt@, and add a new component called @ChatBox@, like so: +In your project, open `MainActivity.kt`, and add a new component called `ChatBox`: -```[kotlin] + +```kotlin @Composable fun ChatBox(room: Room?) { val scope = rememberCoroutineScope() @@ -335,10 +349,12 @@ fun ChatBox(room: Room?) { } } ``` + -* Next, add the @ChatBox@ component to your main app component: +Add the `ChatBox` component to your main app component: -```[kotlin] + +```kotlin @Composable fun App(chatClient: ChatClient) { val roomName = "my-first-room" @@ -367,31 +383,38 @@ fun App(chatClient: ChatClient) { } } ``` + The UI should automatically render the new component, and you should be able to send messages to the room. -* Type a message in the input box and click the send button. You'll see the message appear in the chat box. +Type a message in the input box and click the send button. You'll see the message appear in the chat box. You can also use the Ably CLI to send a message to the room from another environment: -```[sh] - ably rooms messages send my-first-room 'Hello from CLI!' + +```shell +ably rooms messages send my-first-room 'Hello from CLI!' ``` + You'll see the message in your app's chat box UI. If you have sent a message via CLI, it should appear in a different color to the one you sent from the app. -h2(#step-4). Step 5: Edit a message +## Step 5: Edit a message -If your client makes a typo, or needs to update their original message then they can edit it. To do this, you can extend the functionality of the @ChatBox@ component to allow updating of messages. +If your client makes a typo, or needs to update their original message then they can edit it. To do this, you can extend the functionality of the `ChatBox` component to allow updating of messages. Add edited state variable that will contain edited now message or null if message is not edited: -```[kotlin] + +```kotlin var edited: Message? by remember { mutableStateOf(null) } ``` -Update Send button code + + +Update the send button code: -```[kotlin] + +```kotlin Button( onClick = { sending = true @@ -418,10 +441,12 @@ Button( ) } ``` + -* Update the rendering of messages in the chat box to enable the update action in the UI: +Update the rendering of messages in the chat box to enable the update action in the UI: -```[kotlin] + +```kotlin Column( modifier = Modifier .fillMaxWidth() @@ -543,10 +568,12 @@ Column( } } ``` + -* Next, you must update the listener provided to the @DisposableEffect@ to handle the @MessageEventType.Updated@ event: +You must also update the listener provided to `DisposableEffect` to handle the `MessageEventType.Updated` event: -```[kotlin] + +```kotlin DisposableEffect(room) { val subscription = room?.messages?.subscribe { event -> when (event.type) { @@ -563,26 +590,29 @@ DisposableEffect(room) { } } ``` + -* @replaceFirstWith@ function can be implemented: +Implement a `replaceFirstWith` function: -```[kotlin] + +```kotlin inline fun MutableList.replaceFirstWith(replacement: T, predicate: (T) -> Boolean) { val index = indexOfFirst(predicate) if (index != -1) set(index, replacement) } ``` + -* Now, when you click on the edit button, modify text and resend it. +When you click on the edit button in the UI, you can modify the text and it will send the updated message contents to the room. -h2(#step-5). Step 6: Message history and continuity +## Step 6: Message history and continuity -Ably Chat enables you to retrieve previously sent messages in a room. This is useful for providing conversational context when a user first joins a room, or when they subsequently rejoin it later on. The message subscription object exposes the "getPreviousMessages()":https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/-messages-subscription/get-previous-messages.html method to enable this functionality. This method returns a paginated response, which can be queried further to retrieve the next set of messages. -To do this, you need to extend the @ChatBox@ component to include a method to retrieve the last 10 messages when the component mounts. +Ably Chat enables you to retrieve previously sent messages in a room. This is useful for providing conversational context when a user first joins a room, or when they subsequently rejoin it later on. The message subscription object exposes the [`getPreviousMessages()`](https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/-messages-subscription/get-previous-messages.html) method to enable this functionality. This method returns a paginated response, which can be queried further to retrieve the next set of messages. -* In your @MainActivity.kt@ file, add the @DisposableEffect@ in your @ChatBox@ component: +Extend the `ChatBox` component to include a method to retrieve the last 10 messages when the component mounts. In your `MainActivity.kt` file, add the `DisposableEffect` in your `ChatBox` component: -```[kotlin] + +```kotlin fun ChatBox(room: Room?) { val componentScope = rememberCoroutineScope() DisposableEffect(room) { @@ -608,13 +638,15 @@ fun ChatBox(room: Room?) { /* rest of your code */ } ``` + The above code will retrieve the last 10 messages when the component mounts, and set them in the state. -You also can use @collectAsPagingMessagesState@ to automatically subscribe to new messages +You also can use `collectAsPagingMessagesState` to automatically subscribe to new messages and lazily load previous messages as you scroll through the message list. -```[kotlin] + +```kotlin @Composable fun ChatBox(room: Room?, onSendMessage: suspend (String) -> Unit) { val scope = rememberCoroutineScope() @@ -734,20 +766,22 @@ fun ChatBox(room: Room?, onSendMessage: suspend (String) -> Unit) { } } ``` + -Try the following to test this feature: +Do the following to test this out: 1. Use the ably CLI to simulate sending some messages to the room from another client. -2. Refresh the page, this will cause the @ChatBox@ component to mount again and call the @getPreviousMessages()@ method. -3. You should see the last 10 messages appear in the chat box. +2. Refresh the page, this will cause the `ChatBox` component to mount again and call the `getPreviousMessages()` method. +3. You'll see the last 10 messages appear in the chat box. -h2(#step-6). Step 7: Display who is present in the room +## Step 7: Display who is present in the room Display the online status of clients using the presence feature. This enables clients to be aware of one another if they are present in the same room. You can then show clients who else is online, provide a custom status update for each, and notify the room when someone enters it, or leaves it, such as by going offline. -* In your @MainActivity.kt@ file, create a new component called @PresenceStatusUi@ like so: +In your `MainActivity.kt` file, create a new component called `PresenceStatusUi`: -```[kotlin] + +```kotlin @Composable fun PresenceStatusUi(room: Room?) { val members = room?.collectAsPresenceMembers() @@ -763,10 +797,12 @@ fun PresenceStatusUi(room: Room?) { ) } ``` + -* Then add the @PresenceStatusUi@ component to your main app component like so: +Add the `PresenceStatusUi` component to your main app component: -```[kotlin] + +```kotlin @Composable fun App(chatClient: ChatClient) { val roomName = "my-first-room" @@ -796,23 +832,26 @@ fun App(chatClient: ChatClient) { } } ``` + You'll now see your current client ID in the list of present users. -* You can also use the Ably CLI to enter the room from another client by running the following command: +You can also use the Ably CLI to enter the room from another client by running the following command: -```[sh] - ably rooms presence enter my-first-room --client-id "my-cli" + +```shell +ably rooms presence enter my-first-room --client-id "my-cli" ``` + -h2(#step-7). Step 8: Send a reaction +## Step 8: Send a reaction -Clients can send a reaction to a room to show their sentiment for what is happening, such as a point being scored in a sports game. -These are short-lived (ephemeral) and are not stored in the room history. +Clients can send a reaction to a room to show their sentiment for what is happening, such as a point being scored in a sports game. These are short-lived (ephemeral) and are not stored in the room history. -* In your @MainActivity.kt@ file, add a new component called @ReactionBar@, like so: +In your `MainActivity.kt` file, add a new component called `ReactionBar`: -```[react] + +```kotlin @Composable fun ReactionBar(room: Room?) { val scope = rememberCoroutineScope() @@ -884,10 +923,12 @@ fun ReactionBar(room: Room?) { } } ``` + -* Next, add the @ReactionBar@ component to your main app component: +Add the `ReactionBar` component to your main app component: -```[kotlin] + +```kotlin @Composable fun App(chatClient: ChatClient) { val roomName = "my-first-room" @@ -918,21 +959,24 @@ fun App(chatClient: ChatClient) { } } ``` + The above code should display a list of reactions that can be sent to the room. When you click on a reaction, it will send it to the room and display it in the UI. -* You can also send a reaction to the room via the Ably CLI by running the following command: +You can also send a reaction to the room via the Ably CLI by running the following command: -```[sh] - ably rooms reactions send my-first-room πŸ‘ + +```shell +ably rooms reactions send my-first-room πŸ‘ ``` + -h2(#step-8). Step 9: Disconnection and clean up resources +## Step 9: Disconnection and clean up resources -To gracefully close connection and clean up resources, you can subscribe to activity lifecycle events and close connection -when activity on pause or stop, and reconnect when activity is restarts. To do this modify @MainActivity@ class like this: +To gracefully close connection and clean up resources, you can subscribe to activity lifecycle events and close the connection when activity has paused or stopped, and then reconnect when activity resumes. To do this modify `MainActivity` class: -```[kotlin] + +```kotlin class MainActivity : ComponentActivity() { private lateinit var realtimeClient: AblyRealtime private lateinit var chatClient: ChatClient @@ -978,18 +1022,19 @@ class MainActivity : ComponentActivity() { } } ``` + -h2(#next). Next steps +## Next steps Continue exploring Ably Chat with Kotlin: Read more about the concepts covered in this guide: -* Read more about using "rooms":/docs/chat/rooms and sending "messages":/docs/chat/rooms/messages?lang=react. -* Find out more regarding "online status":/docs/chat/rooms/presence?lang=kotlin. -* Understand how to use "typing indicators":/docs/chat/rooms/typing?lang=kotlin. -* Send "reactions":/docs/chat/rooms/reactions?lang=kotlin to your rooms. -* Read into pulling messages from "history":/docs/chat/rooms/history?lang=kotlin and providing context to new joiners. -* Understand "token authentication":/docs/authentication/token-authentication before going to production. +* Read more about using [rooms](/docs/chat/rooms) and sending [messages](/docs/chat/rooms/messages?lang=kotlin). +* Find out more regarding [online status](/docs/chat/rooms/presence?lang=kotlin). +* Understand how to use [typing indicators](/docs/chat/rooms/typing?lang=kotlin). +* Send [reactions](/docs/chat/rooms/reactions?lang=kotlin) to your rooms. +* Read into pulling messages from [history](/docs/chat/rooms/history?lang=kotlin) and providing context to new joiners. +* Understand [token authentication](/docs/authentication/token-authentication) before going to production. Explore the Ably CLI further, -or check out the "Chat Kotlin API references":https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/ for additional functionality. +or check out the [Chat Kotlin API references](https://sdk.ably.com/builds/ably/ably-chat-kotlin/main/dokka/chat-android/com.ably.chat/) for additional functionality. From 9449f49df79e2e6501f8208355212cd016ff28e7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 May 2025 10:54:34 +0100 Subject: [PATCH 3/5] Fix review comments: - Updated import list, so all imports for the `ChatBox` component are predefined - Fixed typos - Improved app layout --- .../docs/chat/getting-started/kotlin.mdx | 278 +++++++----------- 1 file changed, 106 insertions(+), 172 deletions(-) diff --git a/src/pages/docs/chat/getting-started/kotlin.mdx b/src/pages/docs/chat/getting-started/kotlin.mdx index 9417311dc8..92069e63cd 100644 --- a/src/pages/docs/chat/getting-started/kotlin.mdx +++ b/src/pages/docs/chat/getting-started/kotlin.mdx @@ -69,27 +69,38 @@ implementation("com.ably.chat:chat-extensions-compose:") ## Step 1: Setting up Ably -Replace the contents of your `MainActivity.kt` file with the following code to set up the Ably client: +Replace the contents of your `MainActivity.kt` file with the following code to set up the Ably client. +Replace `{{API_KEY}}` with your Ably API key from the dashboard. +Note that this is for example purposes only. In production, you should use token authentication to avoid exposing your API keys publicly: ```kotlin +package com.example.chatexample + import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Colum -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import java.util.UUID +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.icons.* +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.* import com.example.chatexample.ui.theme.ChatExampleTheme import com.ably.chat.* +import com.ably.chat.extensions.compose.* import io.ably.lib.realtime.AblyRealtime import io.ably.lib.types.ClientOptions +import kotlinx.coroutines.launch +import java.text.* +import java.util.* class MainActivity : ComponentActivity() { private lateinit var realtimeClient: AblyRealtime @@ -100,7 +111,7 @@ class MainActivity : ComponentActivity() { realtimeClient = AblyRealtime( ClientOptions().apply { - key = "{{API_KEY}}" + key = "{{API_KEY}}" // In production, you should use token authentication to avoid exposing your API keys publicly clientId = "my-first-client" }, ) @@ -122,6 +133,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { Text("Hello Chat App") @@ -144,7 +156,7 @@ In your `MainActivity.kt` file, add the following `ConnectionStatusUi` composabl fun ConnectionStatusUi(connection: Connection) { val connectionStatus = connection.collectAsStatus() Text( - text = "Connection Status: ${connectionStatus}", + text = "Connection Status: $connectionStatus", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 8.dp) ) @@ -162,6 +174,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { ConnectionStatusUi(connection = chatClient.connection) @@ -199,7 +212,6 @@ Update your main app component to get and attach to the room and nest the `RoomS ```kotlin @Composable fun App(chatClient: ChatClient) { - val connectionStatus = chatClient.connection.collectAsStatus() val roomName = "my-first-room" var room by remember { mutableStateOf(null) } LaunchedEffect(roomName) { @@ -211,6 +223,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { ConnectionStatusUi(connection = chatClient.connection) @@ -246,6 +259,7 @@ fun ChatBox(room: Room?) { val subscription = room?.messages?.subscribe { event -> when (event.type) { MessageEventType.Created -> messages.add(0, event.message) + else -> Unit } } @@ -371,6 +385,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { ConnectionStatusUi(connection = chatClient.connection) @@ -403,7 +418,7 @@ You'll see the message in your app's chat box UI. If you have sent a message via If your client makes a typo, or needs to update their original message then they can edit it. To do this, you can extend the functionality of the `ChatBox` component to allow updating of messages. -Add edited state variable that will contain edited now message or null if message is not edited: +1. Add the edited state variable: ```kotlin @@ -411,188 +426,103 @@ var edited: Message? by remember { mutableStateOf(null) } ``` -Update the send button code: +2. Update the message subscription to handle edited messages: ```kotlin -Button( - onClick = { - sending = true - if (textInput.text.isNotBlank()) { - scope.launch { - try { - edited?.let { - room?.messages?.update(it.copy(text = textInput.text)) - } ?: room?.messages?.send(textInput.text) - } catch (e: Exception) { - Log.e("APP", e.message, e) - } - edited = null - textInput = TextFieldValue("") - sending = false +DisposableEffect(room) { + val subscription = room?.messages?.subscribe { event -> + when (event.type) { + MessageEventType.Created -> messages.add(0, event.message) + MessageEventType.Updated -> messages.replaceFirstWith(event.message) { + it.serial == event.message.serial } + else -> Unit } - }, - enabled = textInput.text.isNotBlank() && !sending -) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = if (edited != null) "Edit" else "Send" - ) + } + + onDispose { + subscription?.unsubscribe() + } } ``` -Update the rendering of messages in the chat box to enable the update action in the UI: +3. Add an edit button to each message: ```kotlin -Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) +Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Card( + Column( modifier = Modifier .weight(1f) - .fillMaxWidth(), - ) { - if (messages.isNullOrEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "No messages yet", - color = Color.Gray - ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - state = messagesState.listState - ) { - items(messages.size, key = { messages[it].serial }) { - val message = messages[it] - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 4.dp) - ) { - Text( - text = "${message.clientId}: ${message.text}", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - .format(Date(message.timestamp)), - style = MaterialTheme.typography.labelSmall, - color = Color.Gray, - modifier = Modifier.align(Alignment.End) - ) - HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) - } - Box { - IconButton(onClick = { expanded = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More Options") - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text("Edit") }, - onClick = { - expanded = false - edited = message - textInput = TextFieldValue(message.text) - }, - ) - } - } - } - } - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Message input - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 4.dp) ) { - OutlinedTextField( - value = textInput, - onValueChange = { textInput = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Type a message") }, - maxLines = 3 + // Message content + Text( + text = "${message.clientId}: ${message.text}", + style = MaterialTheme.typography.bodyMedium ) - - Spacer(modifier = Modifier.width(8.dp)) - - Button( - onClick = { - sending = true - if (textInput.text.isNotBlank()) { - scope.launch { - try { - edited?.let { - room?.messages?.update(it.copy(text = textInput.text)) - } ?: room?.messages?.send(textInput.text) - } catch (e: Exception) { - Log.e("APP", e.message, e) - } - textInput = TextFieldValue("") - sending = false - } - } - }, - enabled = textInput.text.isNotBlank() && !sending - ) { - Icon( - imageVector = if (edited != null) Icons.Default.Edit - else Icons.AutoMirrored.Filled.Send, - contentDescription = if (edited != null) "Edit" else "Send" - ) - } + // Timestamp + Text( + text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + .format(Date(message.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + modifier = Modifier.align(Alignment.End) + ) + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + } + // Edit button + IconButton(onClick = { + edited = message + textInput = TextFieldValue(message.text) + }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") } } ``` -You must also update the listener provided to `DisposableEffect` to handle the `MessageEventType.Updated` event: +4. Update the send button to handle both new messages and edits: ```kotlin -DisposableEffect(room) { - val subscription = room?.messages?.subscribe { event -> - when (event.type) { - MessageEventType.Created -> messages.add(0, event.message) - MessageEventType.Updated -> messages.replaceFirstWith(event.message) { - it.serial == event.message.serial +Button( + onClick = { + sending = true + if (textInput.text.isNotBlank()) { + scope.launch { + try { + edited?.let { + room?.messages?.update(it.copy(text = textInput.text)) + } ?: room?.messages?.send(textInput.text) + } catch (e: Exception) { + Log.e("APP", e.message, e) + } + edited = null + textInput = TextFieldValue("") + sending = false } - else -> Unit } - } - - onDispose { - subscription?.unsubscribe() - } + }, + enabled = textInput.text.isNotBlank() && !sending +) { + Icon( + imageVector = if (edited != null) Icons.Filled.Edit + else Icons.AutoMirrored.Filled.Send, + contentDescription = if (edited != null) "Edit" else "Send" + ) } ``` -Implement a `replaceFirstWith` function: + + +Add this utility function to help with message updates: ```kotlin @@ -614,8 +544,9 @@ Extend the `ChatBox` component to include a method to retrieve the last 10 messa ```kotlin fun ChatBox(room: Room?) { - val componentScope = rememberCoroutineScope() - DisposableEffect(room) { + /* variables declaration */ + + DisposableEffect(room) { val subscription = room?.messages?.subscribe { event -> when (event.type) { MessageEventType.Created -> messages.add(0, event.message) @@ -626,9 +557,9 @@ fun ChatBox(room: Room?) { } } - componentScope.launch { - val previousMessages = subscription?.getPreviousMessages(10) ?: emptyList() - messages += previousMessages + scope.launch { + val previousMessages = subscription?.getPreviousMessages(10)?.items ?: emptyList() + messages.addAll(previousMessages) } onDispose { @@ -648,7 +579,7 @@ and lazily load previous messages as you scroll through the message list. ```kotlin @Composable -fun ChatBox(room: Room?, onSendMessage: suspend (String) -> Unit) { +fun ChatBox(room: Room?) { val scope = rememberCoroutineScope() var textInput by remember { mutableStateOf(TextFieldValue("")) } val messagesState = room?.collectAsPagingMessagesState() @@ -681,7 +612,8 @@ fun ChatBox(room: Room?, onSendMessage: suspend (String) -> Unit) { modifier = Modifier .fillMaxSize() .padding(8.dp), - state = messagesState.listState + reverseLayout = true, + state = messagesState.listState, ) { items(messages.size, key = { messages[it].serial }) { val message = messages[it] @@ -819,6 +751,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { ConnectionStatusUi(connection = chatClient.connection) @@ -945,6 +878,7 @@ fun App(chatClient: ChatClient) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) ) { ConnectionStatusUi(connection = chatClient.connection) From 1f56890e82661bb103390b8e6975d5929d902f72 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 10 Jun 2025 13:02:54 +0100 Subject: [PATCH 4/5] Fix review comments: - put utility function creation at the beginning of "update step" - explain where we add edit button --- .../docs/chat/getting-started/kotlin.mdx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/pages/docs/chat/getting-started/kotlin.mdx b/src/pages/docs/chat/getting-started/kotlin.mdx index 92069e63cd..39cb572473 100644 --- a/src/pages/docs/chat/getting-started/kotlin.mdx +++ b/src/pages/docs/chat/getting-started/kotlin.mdx @@ -418,7 +418,18 @@ You'll see the message in your app's chat box UI. If you have sent a message via If your client makes a typo, or needs to update their original message then they can edit it. To do this, you can extend the functionality of the `ChatBox` component to allow updating of messages. -1. Add the edited state variable: +1. We will begin by adding this utility function to facilitate message data updates in the UI: + + + ```kotlin + inline fun MutableList.replaceFirstWith(replacement: T, predicate: (T) -> Boolean) { + val index = indexOfFirst(predicate) + if (index != -1) set(index, replacement) + } + ``` + + +2. Add the edited state variable: ```kotlin @@ -426,7 +437,7 @@ var edited: Message? by remember { mutableStateOf(null) } ``` -2. Update the message subscription to handle edited messages: +3. Update the message subscription to handle edited messages: ```kotlin @@ -448,7 +459,10 @@ DisposableEffect(room) { ``` -3. Add an edit button to each message: +4. Let's enhance the message display. To add an edit button to each message, we'll first need to locate +the `Row` composable function within your layout. This is the component responsible for rendering +the message content itself, along with the sender's username and the message timestamp. +Once we've identified this `Row`, we will integrate the new edit button alongside these existing elements: ```kotlin @@ -487,7 +501,7 @@ Row( ``` -4. Update the send button to handle both new messages and edits: +5. Update the send button to handle both new messages and edits: ```kotlin @@ -521,18 +535,6 @@ Button( - -Add this utility function to help with message updates: - - -```kotlin -inline fun MutableList.replaceFirstWith(replacement: T, predicate: (T) -> Boolean) { - val index = indexOfFirst(predicate) - if (index != -1) set(index, replacement) -} -``` - - When you click on the edit button in the UI, you can modify the text and it will send the updated message contents to the room. ## Step 6: Message history and continuity From bf8cd0ee014efdb8c49bf98dd9bec728ce2fff30 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 12 Jun 2025 12:35:19 +0100 Subject: [PATCH 5/5] Add sign up URL to kotlin getting started and improved readability of a step --- src/pages/docs/chat/getting-started/kotlin.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/docs/chat/getting-started/kotlin.mdx b/src/pages/docs/chat/getting-started/kotlin.mdx index 39cb572473..376f4a0e14 100644 --- a/src/pages/docs/chat/getting-started/kotlin.mdx +++ b/src/pages/docs/chat/getting-started/kotlin.mdx @@ -21,7 +21,7 @@ It will take you through the following steps: ### Ably -1. Sign up for an Ably account. +1. [Sign up](https://ably.com/signup) for an Ably account. 2. Create a new app and get your API key. You can use the root API key that is provided by default to get started. 3. Install the Ably CLI: @@ -45,6 +45,8 @@ ably auth keys switch ### Create a new project @@ -70,6 +72,7 @@ implementation("com.ably.chat:chat-extensions-compose:") ## Step 1: Setting up Ably Replace the contents of your `MainActivity.kt` file with the following code to set up the Ably client. + Replace `{{API_KEY}}` with your Ably API key from the dashboard. Note that this is for example purposes only. In production, you should use token authentication to avoid exposing your API keys publicly: @@ -460,9 +463,9 @@ DisposableEffect(room) { 4. Let's enhance the message display. To add an edit button to each message, we'll first need to locate -the `Row` composable function within your layout. This is the component responsible for rendering +the `Column` composable function within your layout inside the `items(messages.size, key = { messages[it].serial }) {` declarative loop. This is the component responsible for rendering the message content itself, along with the sender's username and the message timestamp. -Once we've identified this `Row`, we will integrate the new edit button alongside these existing elements: +Once we've identified this `Column`, we will integrate the new edit button alongside these existing elements. Replace the `Column` with the following: ```kotlin