Skip to content

Commit

Permalink
feat: extension for integration with Jetpack Compose
Browse files Browse the repository at this point in the history
Added `com.ably.chat:chat-extensions-compose` package with extension functions for better integration with Jetpack Compose.
  • Loading branch information
ttypic committed Mar 6, 2025
1 parent 802c95f commit 328be26
Show file tree
Hide file tree
Showing 26 changed files with 915 additions and 137 deletions.
4 changes: 2 additions & 2 deletions chat-android/src/main/java/com/ably/chat/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ public data class ConnectionStatusChange(
* An error that provides a reason why the connection has
* entered the new status, if applicable.
*/
val error: ErrorInfo?,
val error: ErrorInfo? = null,

/**
* The time in milliseconds that the client will wait before attempting to reconnect.
*/
val retryIn: Long?,
val retryIn: Long? = null,
)

/**
Expand Down
1 change: 1 addition & 0 deletions chat-android/src/main/java/com/ably/chat/Typing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ internal class DefaultTyping(
logger.trace("DefaultTyping.stop()")
typingScope.launch {
typingJob?.cancel()
typingJob = null
room.ensureAttached(logger) // CHA-T5e, CHA-T5c, CHA-T5d
channelWrapper.presence.leaveClientCoroutine(room.clientId)
}.join()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@file:Suppress("Filename")

package com.ably.chat.annotations

/**
* API marked with this annotation is internal, and it is not intended to be used outside Ably.
* It could be modified or removed without any notice. Using it outside Ably could cause undefined behaviour and/or
* any unexpected effects.
*/
@MustBeDocumented
@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message = "This API is experimental in Ably Chat SDK. Roughly speaking, there is a chance " +
"that those declarations will be deprecated in the near future or the semantics of " +
"their behavior may change in some way that may break some code",
)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.TYPEALIAS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.FIELD,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.PROPERTY_SETTER,
)
public annotation class ExperimentalChatApi
64 changes: 64 additions & 0 deletions chat-extensions-compose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.android.kotlin)
alias(libs.plugins.maven.publish)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.dokka)
}

val version = property("VERSION_NAME")

android {
namespace = "com.ably.chat.extensions.compose"
compileSdk = 34
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
}

buildTypes {
release {
isMinifyEnabled = false

proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}

buildFeatures {
compose = true
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}

@Suppress("UnstableApiUsage")
testOptions {
unitTests {
isReturnDefaultValues = true
}
}
}

kotlin {
explicitApi()
}

dependencies {
implementation(project(":chat-android"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.coroutine.test)
testImplementation(libs.turbine)
testImplementation(libs.molecule)
}
Empty file.
4 changes: 4 additions & 0 deletions chat-extensions-compose/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
POM_ARTIFACT_ID=chat-extensions-compose
POM_NAME=Ably Chat SDK library extensions for Jetpack Compose
POM_DESCRIPTION=Ably Chat SDK library extensions for Jetpack Compose.
POM_PACKAGING=aar
21 changes: 21 additions & 0 deletions chat-extensions-compose/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
3 changes: 3 additions & 0 deletions chat-extensions-compose/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.ably.chat.extensions.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.ably.chat.Connection
import com.ably.chat.ConnectionStatus
import com.ably.chat.annotations.ExperimentalChatApi
import com.ably.chat.statusAsFlow

/**
* @return active connection status
*/
@ExperimentalChatApi
@Composable
public fun Connection.asComposable(): ConnectionStatus {
var connectionStatus by remember(this) { mutableStateOf(status) }

LaunchedEffect(this) {
statusAsFlow().collect { connectionStatus = it.current }
}

return connectionStatus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.ably.chat.extensions.compose

import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.ably.chat.Message
import com.ably.chat.MessageEventType
import com.ably.chat.Messages
import com.ably.chat.PaginatedResult
import com.ably.chat.annotations.ExperimentalChatApi
import kotlinx.coroutines.launch

/**
* @return paginated messages
*/
@ExperimentalChatApi
@Composable
public fun Messages.asComposable(): PaginatedMessages {
val listState = rememberLazyListState()
var loaded by remember(this) { mutableStateOf(listOf<Message>()) }
var loading by remember(this) { mutableStateOf(true) }
var lastReceivedPaginatedResult: PaginatedResult<Message>? by remember(this) { mutableStateOf(null) }

val coroutineScope = rememberCoroutineScope()

DisposableEffect(this) {
val subscription = subscribe { event ->
when (event.type) {
MessageEventType.Created -> {
loaded += event.message
coroutineScope.launch {
listState.animateScrollToItem(loaded.size - 1)
}
}

MessageEventType.Updated -> loaded = loaded.map {
if (it.serial != event.message.serial) it else event.message
}

MessageEventType.Deleted -> loaded = loaded.filter {
it.serial != event.message.serial
}
}
}

coroutineScope.launch {
loading = true
val receivedPaginatedResult = subscription.getPreviousMessages()
lastReceivedPaginatedResult = receivedPaginatedResult
loaded = receivedPaginatedResult.items.reversed() + loaded
loading = false
if (loaded.isNotEmpty()) listState.animateScrollToItem(loaded.size - 1)
}

onDispose {
subscription.unsubscribe()
}
}

return DefaultPaginatedMessages(
loaded = loaded,
listState = listState,
loading = loading,
hasMore = lastReceivedPaginatedResult?.hasNext() ?: true,
)
}

public interface PaginatedMessages {
public val loaded: List<Message>
public val listState: LazyListState
public val loading: Boolean
public val hasMore: Boolean
}

private data class DefaultPaginatedMessages(
override val loaded: List<Message>,
override val listState: LazyListState,
override val loading: Boolean,
override val hasMore: Boolean,
) : PaginatedMessages
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.ably.chat.extensions.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.ably.chat.Occupancy
import com.ably.chat.annotations.ExperimentalChatApi
import com.ably.chat.asFlow
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch

public data class CurrentOccupancy(
val connections: Int = 0,
val presenceMembers: Int = 0,
)

/**
* @return current occupancy
*/
@ExperimentalChatApi
@Composable
public fun Occupancy.asComposable(): CurrentOccupancy {
var currentOccupancy by remember(this) { mutableStateOf(CurrentOccupancy()) }

LaunchedEffect(this) {
val initialOccupancyGet = launch {
val occupancyEvent = get()
currentOccupancy = CurrentOccupancy(
connections = occupancyEvent.connections,
presenceMembers = occupancyEvent.presenceMembers,
)
}
asFlow().collect {
if (initialOccupancyGet.isActive) initialOccupancyGet.cancelAndJoin()
currentOccupancy = CurrentOccupancy(
connections = it.connections,
presenceMembers = it.presenceMembers,
)
}
}

return currentOccupancy
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ably.chat.extensions.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.ably.chat.Presence
import com.ably.chat.PresenceMember
import com.ably.chat.annotations.ExperimentalChatApi
import com.ably.chat.asFlow
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch

/**
* @return currently present members
*/
@ExperimentalChatApi
@Composable
public fun Presence.asComposable(): List<PresenceMember> {
var presenceMembers by remember(this) { mutableStateOf(emptyList<PresenceMember>()) }

LaunchedEffect(this) {
val initialPresenceGet = launch {
presenceMembers = get()
}
asFlow().collect {
if (initialPresenceGet.isActive) initialPresenceGet.cancelAndJoin()
presenceMembers = get()
}
}

return presenceMembers
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.ably.chat.extensions.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.ably.chat.Room
import com.ably.chat.RoomStatus
import com.ably.chat.annotations.ExperimentalChatApi
import com.ably.chat.statusAsFlow

/**
* @return room status
*/
@ExperimentalChatApi
@Composable
public fun Room.asComposable(): RoomStatus {
var roomStatus by remember(this) { mutableStateOf(status) }

LaunchedEffect(this) {
statusAsFlow().collect { roomStatus = it.current }
}

return roomStatus
}
Loading

0 comments on commit 328be26

Please sign in to comment.