-
Notifications
You must be signed in to change notification settings - Fork 278
Client Cookbook
This cheat sheet provides additional information to the official StreamChat SDK documentation on our website. You can find here more detailed information, richer code snippets, and commentary on the provided solutions.
- Overview
- Setup
- Calling SDK methods
- User Types
- Querying Users
- Creating Channels
- Watching a Channel
- Querying Channels
- Paginating Channel Messages
- Updating a Channel
- Changing Channel Members
- Invites
- Deleting and Hiding a Channel
- Muting Channels
- Querying Members
- Slow Mode
- Messages
- File Uploads
- Reactions
- Thread and Replies
- Silent Messages
- Search
- Pinned Messages
- Moderation Tools
- User Presence
- Typing Indicators
- Events
- Logging
Didn't find what you were looking for? Open an issue in our repo and suggest a new topic!
Chat Client or LLC (low-level client) is a low-level client for making API calls and receiving chat events. This library integrates directly with Stream Chat APIs and does not include state handling or UI. This library supports both Kotlin and Java usage, but we strongly recommend using Kotlin.
Update your repositories in the project level build.gradle
file:
allprojects {
repositories {
mavenCentral()
}
}
Open up the app module's build.gradle
script and make the following changes:
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation "io.getstream:stream-chat-android-client:$stream_version"
}
For the latest version, check the Releases page.
As a first step, you need to initialize ChatClient
, which is the main entry point for all operations in the library. You should only create the client once and re-use it across your application. Typically ChatClient
is initialized in Application
class:
class App : Application() {
override fun onCreate() {
super.onCreate()
val client = ChatClient.Builder("apiKey", context).build()
// Static reference to initialised client
val staticClientRef = ChatClient.instance()
}
}
With this, you will be able to retrieve instances of the different components from any part of your application using instance()
. Here's an example:
class MainActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val chatClient = ChatClient.instance()
}
}
The next step is connecting the user. A valid StreamChat token is all you need to properly set your current app user as the current user of ChatClient. This token can't be created locally and it must be provided by your backend.
val user = User(
id = "bender",
extraData = mutableMapOf(
"name" to "Bender",
"image" to "https://bit.ly/321RmWb",
),
)
ChatClient.instance().connectUser(user = user, token = "userToken")
.enqueue { result ->
if (result.isSuccess) {
// Handle success
} else {
// Handle error
}
}
Most SDK methods return a Call
object, which is a pending operation waiting to be executed.
You can run a Call
synchronously, in a blocking way, using the execute
method:
// Only call this from a background thread
val messageResult = channelClient.sendMessage(message).execute()
You can run a Call
asynchronously, on a background thread, using the enqueue
method. The callback passed to enqueue
will be called on the UI thread.
// Safe to call from the main thread
channelClient.sendMessage(message).enqueue { result: Result<Message> ->
if (result.isSuccess) {
val sentMessage = result.data()
} else {
// Handle result.error()
}
}
If you are using Kotlin coroutines, you can also await()
the result of a Call
in a suspending way:
viewModelScope.launch {
// Safe to call from any CoroutineContext
val messageResult = channelClient.sendMessage(message).await()
}
Actions defined in a Call
return Result
objects. These contain either the result of a successful operation or the error that caused the operation to fail.
You can check whether a Result
is successful or an error:
// Exactly one of these will be true for each Result
result.isSuccess
result.isError
If the result was successful, you can get the contained data with data()
. Otherwise, you can read error()
and handle it appropriately.
if (result.isSuccess) {
// Use result.data()
} else {
// Handle result.error()
}
Calling data()
on a failed Result
or calling error()
on a successful Result
will throw an IllegalStateException
.
val user = User(
id = "bender",
extraData = mutableMapOf(
"name" to "Bender",
"image" to "https://bit.ly/321RmWb",
),
)
// You can set up a user token in two ways:
// 1. Setup the current user with a JWT token
val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFuY3ktbW9kZS0wIn0.rSnrWOv8EbsiYzJlvVwqwCgATZ1Magj_fZl-bZyCHKI"
client.connectUser(user, token).enqueue { result ->
if (result.isSuccess) {
// Logged in
val user: User = result.data().user
val connectionId: String = result.data().connectionId
} else {
// Handle result.error()
}
}
// 2. Setup the current user with a TokenProvider
val tokenProvider = object : TokenProvider {
// Make a request to your backend to generate a valid token for the user
override fun loadToken(): String = yourTokenService.getToken(user)
}
client.connectUser(user, tokenProvider).enqueue { /* ... */ }
Guest sessions can be created client-side and do not require any server-side authentication. Support and livestreams are common use cases for guest users because often you want a visitor to be able to use chat on your application without (or before) they have a regular user account.
Guest users are not available to application using multi-tenancy (teams).
Unlike anonymous users, guest users are counted towards your MAU usage.
Guest users have a limited set of permissions. You can create a guest user session by using connectGuestUser
instead of connectUser
.
client.connectGuestUser(userId = "bender", username = "Bender").enqueue { /*... */ }
If a user is not logged in, you can call the connectAnonymousUser
method. While you’re anonymous, you can’t do much, but for the livestream
channel type, you’re still allowed to read the chat conversation.
client.connectAnonymousUser().enqueue { /*... */ }
When you connect to chat using anonymously you receive a special user back with the following data:
{
"id": "!anon",
"role": "anonymous",
"roles": [],
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"last_active": "2020-11-02T18:36:01.125136Z",
"banned": false,
"online": true,
"invisible": false,
"devices": [],
"mutes": [],
"channel_mutes": [],
"unread_count": 0,
"total_unread_count": 0,
"unread_channels": 0,
"language": ""
}
Anonymous users are not counted toward your MAU number and only have an impact on the number of concurrent connected clients.
The Query Users method allows you to search for users and see if they are online/offline. The example below shows how you can retrieve the details for 3 users in one API call:
// Search for users with id "john", "jack", or "jessie"
val request = QueryUsersRequest(
filter = Filters.`in`("id", listOf("john", "jack", "jessie")),
offset = 0,
limit = 3,
)
client.queryUsers(request).enqueue { result ->
if (result.isSuccess) {
val users: List<User> = result.data()
} else {
// Handle result.error()
}
}
Another option is to query for banned users. This can be done with the following code snippet:
val request = QueryUsersRequest(
filter = Filters.eq("banned", true),
offset = 0,
limit = 10,
)
client.queryUsers(request).enqueue { /* ... */ }
Please be aware that this query will return users banned across the entire app, not at a channel level.
You can filter and sort on the custom fields you've set for your user, the user id, and when the user was last active.
The options for the queryUser
method are presence
, limit
, and offset
. If presence
is true
this makes sure you receive the user.presence.changed
event when a user goes online or offline.
You can autocomplete the results of your user query by username and/or ID.
If you want to return all users whose username includes 'ro', you could do so with the following:
al request = QueryUsersRequest(
filter = Filters.autocomplete("name", "ro"),
offset = 0,
limit = 10,
)
client.queryUsers(request).enqueue { /* ... */ }
This would return an array of any matching users, such as:
[
{
"id": "userID",
"name": "Curiosity Rover"
},
{
"id": "userID2",
"name": "Roxy"
},
{
"id": "userID3",
"name": "Roxanne"
}
]
Both channel channel.query
and channel.watch
methods ensure that a channel exists and create one otherwise. If all you need is to ensure that a channel exists, you can use channel.create
.
val channelClient = client.channel(channelType = "messaging", channelId = "general")
channelClient.create().enqueue { result ->
if (result.isSuccess) {
val newChannel: Channel = result.data()
} else {
// Handle result.error()
}
}
Channels can be used to conversations between users. In most cases, you want conversations to be unique and make sure that a group of users have only a channel.
You can achieve this by leaving the channel ID empty and provide channel type and members. When you do so, the API will ensure that only one channel for the members you specified exists (the order of the members does not matter).
You cannot add/remove members for channels created this way.
client.createChannel(
channelType = "messaging",
members = listOf("thierry", "tomasso")
).enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
} else {
// Handle result.error()
}
}
The call to channel.watch
does a few different things in one API call:
- It creates the channel if it doesn't exist yet (if this user has the right permissions to create a channel)
- It queries the channel state and returns members, watchers and messages
- It watches the channel state and tells the server that you want to receive events when anything in this channel changes
The examples below show how to watch a channel. Note that you need to be connected as a user before you can watch a channel.
val channelClient = client.channel(channelType = "messaging", channelId = "general")
channelClient.watch().enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
} else {
// Handle result.error()
}
}
Watching a channel only works if you have connected as a user to the chat API
The default queryChannels
API returns channels and starts watching them. There is no need to also use channel.watch
on the channels returned from queryChannels
.
val request = QueryChannelsRequest(
filter = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf(currentUserId)),
),
offset = 0,
limit = 10,
querySort = QuerySort.desc("last_message_at")
).apply {
// Watches the channels automatically
watch = true
state = true
}
// Run query on ChatClient
client.queryChannels(request).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
To stop receiving channel events:
channelClient.stopWatching().enqueue { result ->
if (result.isSuccess) {
// Channel unwatched
} else {
// Handle result.error()
}
}
To get the watcher count of a channel:
val request = QueryChannelRequest().withState()
channelClient.query(request).enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
channel.watcherCount
} else {
// Handle result.error()
}
}
val request = QueryChannelRequest()
.withWatchers(limit = 5, offset = 0)
channelClient.query(request).enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
val watchers: List<User> = channel.watchers
} else {
// Handle result.error()
}
}
A user already watching the channel can listen to users starting and stopping watching the channel with the real-time events:
// Start watching channel
channelClient.watch().enqueue {
/* Handle result */
}
// Subscribe for watching events
channelClient.subscribeFor(
UserStartWatchingEvent::class,
UserStopWatchingEvent::class,
) { event ->
when (event) {
is UserStartWatchingEvent -> {
// User who started watching the channel
val user = event.user
}
is UserStopWatchingEvent -> {
// User who stopped watching the channel
val user = event.user
}
}
}
You can query channels based on built-in fields as well as any custom field you add to channels. Multiple filters can be combined using AND, OR logical operators, each filter can use its comparison (equality, inequality, greater than, greater or equal, etc.). You can find the complete list of supported operators in the query syntax section of the docs.
As an example, let's say that you want to query the last conversations I participated in sorted by last_message_at
.
Here’s an example of how you can query the list of channels:
val request = QueryChannelsRequest(
filter = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf("thierry")),
),
offset = 0,
limit = 10,
querySort = QuerySort.desc("last_message_at")
).apply {
watch = true
state = true
}
client.queryChannels(request).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
At a minimum, the filter should include members: { $in: [userID] }.
On messaging and team applications you normally have users added to channels as a member. A good starting point is to use this filter to show the channels the user is participating.
val filter = Filters.`in`("members", listOf("thierry"))
On a support chat, you probably want to attach additional information to channels such as the support agent handling the case and other information regarding the status of the support case (ie. open, pending, solved).
val filter = Filters.and(
Filters.eq("agent_id", user.id),
Filters.`in`("status", listOf("pending", "open", "new")),
)
Query channel requests can be paginated similar to how you paginate on other calls. Here's a short example:
// Get the first 10 channels
val filter = Filters.`in`("members", "thierry")
val offset = 0
val limit = 10
val request = QueryChannelsRequest(filter, offset, limit)
client.queryChannels(request).enqueue { result ->
if (result.isSuccess) {
val channels = result.data()
} else {
// Handle result.error()
}
}
// Get the second 10 channels
val nextRequest = QueryChannelsRequest(
filter = filter,
offset = 10, // Skips first 10
limit = limit
)
client.queryChannels(nextRequest).enqueue { result ->
if (result.isSuccess) {
val channels = result.data()
} else {
// Handle result.error()
}
}
The channel query endpoint allows you to paginate the list of messages, watchers, and members for one channel. To make sure that you can retrieve a consistent list of messages, pagination does not work with simple offset/limit parameters but instead, it relies on passing the ID of the messages from the previous page.
For example: say that you fetched the first 100 messages from a channel and want to lead the next 100. To do this you need to make a channel query request and pass the ID of the oldest message if you are paginating in descending order or the ID of the newest message if paginating in ascending order.
Use the id_lt
parameter to retrieve messages older than the provided ID and id_gt
to retrieve messages newer than the provided ID.
The terms id_lt
and id_gt
stand for ID less than and ID greater than.
ID-based pagination improves performance and prevents issues related to the list of messages changing while you’re paginating. If needed, you can also use the inclusive versions of those two parameters: id_lte
and id_gte
.
val channelClient = client.channel("messaging", "general")
val pageSize = 10
// Request for the first page
val request = QueryChannelRequest()
.withMessages(pageSize)
channelClient.query(request).enqueue { result ->
if (result.isSuccess) {
val messages: List<Message> = result.data().messages
if (messages.size < pageSize) {
// All messages loaded
} else {
// Load next page
val nextRequest = QueryChannelRequest()
.withMessages(LESS_THAN, messages.last().id, pageSize)
// ...
}
} else {
// Handle result.error()
}
}
For members and watchers, we use limit and offset parameters.
The maximum number of messages that can be retrieved at once from the API is 300.
There are two ways to update a channel using the Stream API - a partial or full update. A partial update will retain any custom key-value data, whereas a complete update is going to remove any that are unspecified in the API request.
A partial update can be used to set and unset specific fields when it is necessary to retain additional custom data fields on the object. AKA a patch style update.
// Here's a channel with some custom field data that might be useful
val channelClient = client.channel(channelType = "messaging", channelId = "general")
channelClient.create(
members = listOf("thierry", "tomasso"),
extraData = mapOf(
"source" to "user",
"source_detail" to mapOf("user_id" to 123),
"channel_detail" to mapOf(
"topic" to "Plants and Animals",
"rating" to "pg"
)
)
).execute()
// let's change the source of this channel
channelClient.updatePartial(set = mapOf("source" to "system")).execute()
// since it's system generated we no longer need source_detail
channelClient.updatePartial(unset = listOf("source_detail")).execute()
// and finally update one of the nested fields in the channel_detail
channelClient.updatePartial(set = mapOf("channel_detail.topic" to "Nature")).execute()
// and maybe we decide we no longer need a rating
channelClient.updatePartial(unset = listOf("channel_detail.rating")).execute()
The updateChannel
function updates all of the channel data. Any data that is present on the channel and not included in a full update will be deleted.
val channelClient = client.channel("messaging", "general")
channelClient.update(
message = Message(
text = "Thierry changed the channel color to green"
),
extraData = mapOf(
"name" to "myspecialchannel",
"color" to "green",
),
).enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
} else {
// Handle result.error()
}
}
Using the addMembers()
method adds the given users as members:
val channelClient = client.channel("messaging", "general")
// Add members with ids "thierry" and "josh"
channelClient.addMembers("thierry", "josh").enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
} else {
// Handle result.error()
}
}
Using the removeMembers()
method removes the given users from members:
val channelClient = client.channel("messaging", "general")
// Remove member with id "tommaso"
channelClient.removeMembers("tommaso").enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
} else {
// Handle result.error()
}
}
Stream Chat provides the ability to invite users to a channel via the channel
method with the invites
array. Upon invitation, the end-user will receive a notification that they were invited to the specified channel.
See the following for an example on how to invite a user by adding an invites
array containing the user ID:
val channelClient = client.channel("messaging", "general")
val data = mapOf(
"members" to listOf("thierry", "tommaso"),
"invites" to listOf("nick")
)
channelClient.create(data).enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
} else {
// Handle result.error()
}
}
In order to accept an invite, you must use call the acceptInvite
method. The acceptInvite
method accepts and object with an optional message property. Please see below for an example of how to call acceptInvite
:
channelClient.acceptInvite(
message = "Nick joined this channel!"
).enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
} else {
// Handle result.error()
}
}
To reject an invite, call the rejectInvite
method. This method does not require a user ID as it pulls the user ID from the current session in store from the connectUser
call.
channelClient.rejectInvite().enqueue { result ->
if (result.isSuccess) {
// Invite rejected
} else {
// Handle result.error()
}
}
Querying for accepted invites is done via the queryChannels
method. This allows you to return a list of accepted invites with a single call. See below for an example:
val request = QueryChannelsRequest(
filter = Filters.eq("invite", "accepted"),
offset = 0,
limit = 10
)
client.queryChannels(request).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
Similar to querying for accepted invites, you can query for rejected invites with queryChannels
. See below for an example:
val request = QueryChannelsRequest(
filter = Filters.eq("invite", "rejected"),
offset = 0,
limit = 10
)
client.queryChannels(request).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
You can delete a Channel using the delete
method. This marks the channel as deleted and hides all the content.
val channelClient = client.channel("messaging", "general")
channelClient.delete().enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
} else {
// Handle result.error()
}
}
Hiding a channel will remove it from query channel requests for that user until a new message is added. Please keep in mind that hiding a channel is only available to members of that channel.
Optionally you can also clear the entire message history of that channel for the user. This way, when a new message is received, it will be the only one present in the channel.
// Hides the channel until a new message is added there
channelClient.hide().enqueue { result ->
if (result.isSuccess) {
// Channel is hidden
} else {
// Handle result.error()
}
}
// Shows a previously hidden channel
channelClient.show().enqueue { result ->
if (result.isSuccess) {
// Channel is shown
} else {
// Handle result.error()
}
}
// Hide the channel and clear the message history
channelClient.hide(clearHistory = true).enqueue { result ->
if (result.isSuccess) {
// Channel is hidden
} else {
// Handle result.error()
}
}
Messages added to a channel will not trigger push notifications, nor change the unread count for the users that muted it.
By default, mutes stay in place indefinitely until the user removes it; however, you can optionally set an expiration time.
client.muteChannel(channelType, channelId)
.enqueue { result: Result<Unit> ->
if (result.isSuccess) {
//channel is muted
} else {
result.error().printStackTrace()
}
}
The list of muted channels and their expiration time is returned when the user connects.
// get list of muted channels when user is connected
client.connectUser(user, "user-token", object : InitConnectionListener() {
override fun onSuccess(data: ConnectionData) {
val user = data.user
// mutes contains the list of channel mutes
val mutes: List<ChannelMute> = user.channelMutes
}
})
// get updates about muted channels
client
.events()
.subscribe { event: ChatEvent? ->
if (event is NotificationChannelMutesUpdated) {
val mutes = event.me.channelMutes
} else if (event is NotificationMutesUpdated) {
val mutes = event.me.channelMutes
}
}
Messages added to muted channels do not increase the unread messages count.
Muted channels can be filtered or excluded by using the muted
in your query channels filter.
// Filter for all channels excluding muted ones
val notMutedFilter = Filters.and(
Filters.eq("muted", false),
Filters.`in`("members", listOf(currentUserId)),
)
// Filter for muted channels
val mutedFilter = Filters.eq("muted", true)
// Executing a channels query with either of the filters
client.queryChannels(QueryChannelsRequest(
filter = filter, // Set the correct filter here
offset = 0,
limit = 10,
)).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
// Unmute channel for current user
channelClient.unmute().enqueue { result ->
if (result.isSuccess) {
// Channel is unmuted
} else {
// Handle result.error()
}
}
Sometimes channels will have many hundreds (or thousands) of members and it is important to be able to access ID's and information on all of these members. The queryMembers
endpoint queries the channel members and allows the user to paginate through a full list of users in channels with very large member counts. The endpoint supports filtering on numerous criteria to efficiently return member information.
The members are sorted by created_at in ascending order.
Stream Chat does not run MongoDB on the backend, only a subset of the query options are available.
Here’s some example of how you can query the list of members:
val channelClient = client.channel("messaging", "general")
val offset = 0 // Use this value for pagination
val limit = 10
val sort = QuerySort<Member>()
// Channel members can be queried with various filters
// 1. Create the filter, e.g query members by user name
val filterByName = Filters.eq("name", "tommaso")
// 2. Call queryMembers with that filter
channelClient.queryMembers(offset, limit, filterByName, sort).enqueue { result ->
if (result.isSuccess) {
val members: List<Member> = result.data()
} else {
Log.e(TAG, String.format("There was an error %s", result.error()), result.error().cause)
}
}
// Here are some other commons filters you can use:
// Autocomplete members by user name (names containing "tom")
val filterByAutoCompleteName = Filters.autocomplete("name", "tom")
// Query member by id
val filterById = Filters.eq("id", "tommaso")
// Query multiple members by id
val filterByIds = Filters.`in`("id", listOf("tommaso", "thierry"))
// Query channel moderators
val filterByModerator = Filters.eq("is_moderator", true)
// Query for banned members in channel
val filterByBannedMembers = Filters.eq("banned", true)
// Query members with pending invites
val filterByPendingInvite = Filters.eq("invite", "pending")
// Query all the members
val filterByNone = FilterObject()
// Results can also be orderd with the QuerySort param
// For example, this will order results by member creation time, descending
val createdAtDescendingSort = QuerySort<Member>().desc("created_at")
Slow mode helps reduce noise on a channel by limiting users to a maximum of 1 message per cooldown interval.
Slow mode is disabled by default and can be enabled/disabled by admins and moderators.
val channelClient = client.channel("messaging", "general")
// Enable slow mode and set cooldown to 1s
channelClient.enableSlowMode(cooldownTimeInSeconds = 1).enqueue { /* Result handling */ }
// Increase cooldown to 30s
channelClient.enableSlowMode(cooldownTimeInSeconds = 30).enqueue { /* Result handling */ }
// Disable slow mode
channelClient.disableSlowMode().enqueue { /* Result handling */ }
When a user posts a message during the cooldown period, the API returns an error message.
You can avoid hitting the APIs and instead show such limitation on the send message UI directly. When slow mode is enabled, channels include a cooldown field containing the current cooldown
period in seconds.
val channelClient = client.channel("messaging", "general")
// Get the cooldown value
channelClient.query(QueryChannelRequest()).enqueue { result ->
if (result.isSuccess) {
val channel = result.data()
val cooldown = channel.cooldown
val message = Message(text = "Hello")
channelClient.sendMessage(message).enqueue {
// After sending a message, block the UI temporarily
// The disable/enable UI methods have to be implemented by you
disableMessageSendingUi()
Handler(Looper.getMainLooper())
.postDelayed(::enableMessageSendingUi, cooldown.toLong())
}
}
}
You can send a simple message using the sendMessage
call:
val channelClient = client.channel("messaging", "general")
val message = Message( text = "Sample message text" )
channelClient.sendMessage(message).enqueue { result ->
if (result.isSuccess) {
val sentMessage: Message = result.data()
} else {
// Handle result.error()
}
}
You can send a message with an attachment using the sendMessage
call:
// Create an image attachment
val attachment = Attachment(
type = "image",
imageUrl = "https://bit.ly/2K74TaG",
thumbUrl = "https://bit.ly/2Uumxti",
extraData = mutableMapOf("myCustomField" to 123),
)
// Create a message with the attachment and a user mention
val message = Message(
text = "@Josh I told them I was pesca-pescatarian. Which is one who eats solely fish who eat other fish.",
attachments = mutableListOf(attachment),
mentionedUsersIds = mutableListOf("josh-id"),
extraData = mutableMapOf("anotherCustomField" to 234),
)
// Send the message to the channel
channelClient.sendMessage(message).enqueue { /* ... */ }
You can get a single message by its ID using the getMessage
call:
channelClient.getMessage("message-id").enqueue { result ->
if (result.isSuccess) {
val message = result.data()
} else {
// Handle result.error()
}
}
You can edit a message by calling updateMessage
and including a message with an ID:
// Update some field of the message
message.text = "my updated text"
// Send the message to the channel
channelClient.updateMessage(message).enqueue { result ->
if (result.isSuccess) {
val updatedMessage = result.data()
} else {
// Handle result.error()
}
}
You can delete a message by calling deleteMessage
and including a message with an ID:
channelClient.deleteMessage("message-id").enqueue { result ->
if (result.isSuccess) {
val deletedMessage = result.data()
} else {
// Handle result.error()
}
}
The channel.sendImage
and channel.sendFile
methods make it easy to upload files.
This functionality defaults to using the Stream CDN. If you would like, you can easily change the logic to upload to your own CDN of choice.
The maximum file size is 20mb for the Stream Chat CDN.
val channelClient = client.channel("messaging", "general")
// Upload an image without detailed progress
channelClient.sendImage(imageFile).enqueue { result->
if (result.isSuccess) {
// Successful upload, you can now attach this image
// to an message that you then send to a channel
val imageUrl = result.data()
val attachment = Attachment(
type = "image",
imageUrl = imageUrl,
)
val message = Message(
attachments = mutableListOf(attachment),
)
channelClient.sendMessage(message).enqueue { /* ... */ }
}
}
In the code example above, note how the message attachments are created after the files are uploaded.
// Upload a file, monitoring for progress with a ProgressCallback
channelClient.sendFile(anyOtherFile, object : ProgressCallback {
override fun onSuccess(file: String) {
val fileUrl = file
}
override fun onError(error: ChatError) {
// Handle error
}
override fun onProgress(progress: Long) {
// You can render the uploading progress here
}
}).enqueue() // No callback passed to enqueue, as we'll get notified above anyway
You can use your own CDN. You'll have to create your own implementation of the FileUploader interface, and any upload and delete calls will be sent to that implementation.
The code examples below show how to change where files are uploaded:
// Set a custom FileUploader implementation when building your client
val client = ChatClient.Builder("39mr6a3z4tem", context)
.fileUploader(MyFileUploader())
.build()
}
Stream Chat has built-in support for user Reactions. Common examples are likes, comments, loves, etc.
val channelClient = client.channel("messaging", "general")
val reaction = Reaction(
messageId = "message-id",
type = "like",
score = 1
)
channelClient.sendReaction(reaction).enqueue { result ->
if (result.isSuccess) {
val sentReaction: Reaction = result.data()
} else {
// Handle result.error()
}
}
Add reaction 'like' and replace all other reactions of this user by it:
channelClient.sendReaction(reaction, enforceUnique = true).enqueue { result ->
if (result.isSuccess) {
val sentReaction = result.data()
} else {
// Handle result.error()
}
}
channelClient.deleteReaction(
messageId = "message-id",
reactionType = "like",
).enqueue { result ->
if (result.isSuccess) {
val message = result.data()
} else {
// Handle result.error()
}
}
Messages returned by the APIs automatically include the 10 most recent reactions. You can also retrieve more reactions and paginate using the following logic:
// Get the first 10 reactions
channelClient.getReactions(
messageId = "message-id",
offset = 0,
limit = 10,
).enqueue { result ->
if (result.isSuccess) {
val reactions: List<Reaction> = result.data()
} else {
// Handle result.error()
}
}
// Get the second 10 reactions
channelClient.getReactions(
messageId = "message-id",
offset = 10,
limit = 10,
).enqueue { /* ... */ }
// Get 10 reactions after particular reaction
channelClient.getReactions(
messageId = "message-id",
firstReactionId = "reaction-id",
limit = 10,
).enqueue { /* ... */ }
You can use the Reactions API to build something similar to Medium's clap reactions. If you are not familiar with this, Medium allows you to clap articles more than once and shows the sum of all claps from all users.
val reaction = Reaction(messageId = "message-id", type = "clap", score = 5)
channelClient.sendReaction(reaction).enqueue { /* ... */ }
Threads can be very helpful to keep the conversation organized and reduce noise.
To create a thread you simply send a message with a parent_id
. Have a look at the example below:
val message = Message(
text = "Hello there!",
parentId = parentMessage.id,
)
// Send the message to the channel
channelClient.sendMessage(message).enqueue { result ->
if (result.isSuccess) {
val sentMessage = result.data()
} else {
// Handle result.error()
}
}
If you specify
show_in_channel
, the message will be visible both in a thread of replies as well as the main channel.
Messages inside a thread can also have reactions, attachments and mention as any other message.
When you read a channel you do not receive messages inside threads. The parent message includes the count of replies which it is usually what apps show as the link to the thread screen. Reading a thread and paginating its messages works in a very similar way as paginating a channel.
// Retrieve the first 20 messages inside the thread
client.getReplies(parentMessage.id, limit = 20).enqueue { result ->
if (result.isSuccess) {
val replies: List<Message> = result.data()
} else {
// Handle result.error()
}
}
// Retrieve the 20 more messages before the message with id "42"
client.getRepliesMore(
messageId = parentMessage.id,
firstId = "42",
limit = 20,
).enqueue { /* ... */ }
Instead of replying in a thread, it's also possible to quote a message. Quoting a message doesn't result in the creation of a thread; the message is quoted inline.
To quote a message, simply provide the quoted_message_id
field when sending a message:
val message = Message(
text = "This message quotes another message!",
replyMessageId = originalMessage.id,
)
channelClient.sendMessage(message).enqueue { /* ... */ }
Based on the provided quoted_message_id
, the quoted_message
field is automatically enriched when querying channels with messages. Example response:
{
"id": "message_with_quoted_message",
"text": "This is the first message that quotes another message",
"quoted_message_id": "first_message_id",
"quoted_message": {
"id": "first_message_id",
"text": "The initial message"
}
}
Silent messages are special messages that don't increase the unread messages count nor mark a channel as unread.
Creating a silent message is very simple, you only need to include the silent
field boolean field and set it to true
.
val message = Message(
text = "You and Jane are now matched!",
user = systemUser,
silent = true,
)
channelClient.sendMessage(message).enqueue { /* ... */ }
Existing messages cannot be turned into a silent message or vice versa. Silent messages do send push notifications
Message search is built-in to the chat API. You can enable and/or disable the search indexing on a per-channel type.
The command shown below selects the channels in which John is a member. Next, it searches the messages in those channels for the keyword “'supercalifragilisticexpialidocious'”:
client.searchMessages(
SearchMessagesRequest(
offset = 0,
limit = 10,
channelFilter = Filters.`in`("members", listOf("john")),
messageFilter = Filters.autocomplete("text", "supercalifragilisticexpialidocious")
)
).enqueue { result ->
if (result.isSuccess) {
val messages: List<Message> = result.data()
} else {
// Handle result.error()
}
}
Pagination works via the standard limit
and offset
parameters. The first argument, filter
, uses a MongoDB style query expression.
Additionally, this endpoint can be used to search for messages that have attachments.
channelClient.getMessagesWithAttachments(
offset = 0,
limit = 10,
type = "image",
).enqueue { result ->
if (result.isSuccess) {
// These messages will contain at least one of the desired
// type of attachment, but not necessarily all of their
// attachments will have the specified type
val messages: List<Message> = result.data()
}
}
Pinned messages allow users to highlight important messages, make announcements, or temporarily promote content. Pinning a message is, by default, restricted to certain user roles, but this is flexible. Each channel can have multiple pinned messages and these can be created or updated with or without an expiration.
An existing message can be updated to be pinned or unpinned by using the channel.pinMessage
and channel.unpinMessage
methods. Or a new message can be pinned when it is sent by setting the pinned and pin_expires
fields when using channel.sendMessage
.
// Create pinned message
val pinExpirationDate = Calendar.getInstance().apply { set(2077, 1, 1) }.time
val message = Message(
text = "Hey punk",
pinned = true,
pinExpires = pinExpirationDate
)
channelClient.sendMessage(message).enqueue { /* ... */ }
// Unpin message
channelClient.unpinMessage(message).enqueue { /* ... */ }
// Pin message for 120 seconds
channelClient.pinMessage(message, timeout = 120).enqueue { /* ... */ }
// Change message expiration to 2077
channelClient.pinMessage(message, expirationDate = pinExpirationDate).enqueue { /* ... */ }
// Remove expiration date from pinned message
channelClient.pinMessage(message, expirationDate = null).enqueue { /* ... */ }
To pin the message user has to have
PinMessage
permission.
You can easily retrieve the last 10 pinned messages from the channel.pinned_messages
field:
channelClient.query(QueryChannelRequest()).enqueue { result ->
if (result.isSuccess) {
val pinnedMessages: List<Message> = result.data().pinnedMessages
} else {
// Handle result.error()
}
}
Stream Chat also provides a search filter in case if you need to display more than 10 pinned messages in a specific channel.
val request = SearchMessagesRequest(
offset = 0,
limit = 30,
channelFilter = Filters.`in`("cid", "channelType:channelId"),
messageFilter = Filters.eq("pinned", true)
)
client.searchMessages(request).enqueue { result ->
if (result.isSuccess) {
val pinnedMessages = result.data()
} else {
// Handle result.error()
}
}
Any user is allowed to flag a message.
client.flagMessage("message-id").enqueue { result ->
if (result.isSuccess) {
// Message was flagged
val flag: Flag = result.data()
} else {
// Handle result.error()
}
}
client.flagUser("user-id").enqueue { result ->
if (result.isSuccess) {
// User was flagged
val flag: Flag = result.data()
} else {
// Handle result.error()
}
}
Any user is allowed to mute another user. Mutes are stored at the user level and returned with the rest of the user information when connectUser is called. A user will be muted until the user is unmuted or the mute is expired.
client.muteUser("user-id").enqueue { result ->
if (result.isSuccess) {
// User was muted
val mute: Mute = result.data()
} else {
// Handle result.error()
}
}
client.unmuteUser("user-id").enqueue { result ->
if (result.isSuccess) {
// User was unmuted
} else {
// Handle result.error()
}
}
After muting a user messages will still be delivered via web-socket. Implementing business logic such as hiding messages from muted users or display them differently is left to the developer to implement.
Messages from muted users are not delivered via push (APN/Firebase)
Users can be banned from an app entirely or from a channel. When a user is banned, it will not be allowed to post messages until the ban is removed or expired but it will be able to connect to Chat and to channels as before.
In most cases, only admins or moderators are allowed to ban other users from a channel.
Banning a user from all channels can only be done using server-side auth.
// Ban user for 60 minutes from a channel
channelClient.banUser(targetId = "user-id", reason = "Bad words", timeout = 60).enqueue { result ->
if (result.isSuccess) {
// User was banned
} else {
// Handle result.error()
}
}
channelClient.unBanUser(targetId = "user-id").enqueue { result ->
if (result.isSuccess) {
// User was unbanned
} else {
// Handle result.error()
}
}
You can list banned users from the API directly using the user search endpoint or the member search endpoint. You use the first to get the list of users banned from all channels and the second to list users that are banned from a specific channel. Both endpoints support additional query parameters as well as pagination, you can find the full information on the specific doc sections.
// retrieve the list of banned users
client.queryUsers(
QueryUsersRequest(
filter = Filters.eq("banned", true),
offset = 0,
limit = 10,
)
).enqueue { result ->
if (result.isSuccess) {
val users: List<User> = result.data()
} else {
// Handle result.error()
}
}
// query for banned members in channel
channelClient.queryMembers(
offset = 0,
limit = 10,
filter = Filters.eq("banned", true),
sort = QuerySort(),
members = emptyList()
).enqueue { result ->
if (result.isSuccess) {
val members: List<Member> = result.data()
} else {
// Handle result.error()
}
}
You can list banned users from a specific channel using the query banned users endpoint which allows you to get paginated results:
// Get the bans for channel livestream:123 in descending order
channelClient.queryBannedUsers(
sort = QuerySort.desc(BannedUsersSort::createdAt),
).enqueue { result ->
if (result.isSuccess) {
val bannedUsers: List<BannedUser> = result.data()
} else {
// Handle result.error()
}
}
// Get the page of bans which where created before or equal date for the same channel
client.queryBannedUsers(
filter = Filters.eq("channel_cid", "livestream:123"),
sort = QuerySort.desc(BannedUsersSort::createdAt),
createdAtBeforeOrEqual = Date(),
).enqueue { result ->
if (result.isSuccess) {
val bannedUsers: List<BannedUser> = result.data()
} else {
// Handle result.error()
}
}
You can also use in
filter to query banned users from multiple channels:
client.queryBannedUsers(
filter = Filters.`in`("channel_cid", listOf("livestream:123", "livestream:456")),
sort = QuerySort.desc(BannedUsersSort::createdAt),
createdAtBeforeOrEqual = Date(),
).enqueue { result ->
if (result.isSuccess) {
val bannedUsers: List<BannedUser> = result.data()
} else {
// Handle result.error()
}
}
Users can be shadow banned from an app entirely or from a channel. When a user is shadow banned, it will still be allowed to post messages, but any message sent during, will have shadowed: true
field. However, this will be invisible to the author of the message.
It's up to the client-side implementation to hide or otherwise handle these messages appropriately.
// Shadow ban user for 60 minutes from a channel
channelClient.shadowBanUser(targetId = "user-id", reason = "Bad words", timeout = 60).enqueue { result ->
if (result.isSuccess) {
// User was shadow banned
} else {
// Handle result.error()
}
}
channelClient.removeShadowBan("user-id").enqueue { result ->
if (result.isSuccess) {
// Shadow ban was removed
} else {
// Handle result.error()
}
}
Administrators can view shadow banned user status in queryChannels()
, queryMembers()
and queryUsers()
.
User presence allows you to show when a user was last active and if they are online right now. Whenever you read a user the data will look like this:
{
id: 'unique_user_id',
online: true,
status: 'Eating a veggie burger...',
last_active: '2019-01-07T13:17:42.375Z'
}
The online field indicates if the user is online. The status field stores text indicating the current user status.
To mark a user invisible simply set the invisible
property to true. You can also set a custom status message at the same time:
val user = User(
id = "user-id",
invisible = true,
)
client.connectUser(user, "user-token").enqueue { result ->
if (result.isSuccess) {
val user: ConnectionData = result.data()
} else {
// Handle result.error()
}
}
When invisible is set to true, the current user will appear as offline to other users.
NOTE: User's invisible status can only be set while calling
connectUser
method.
These 3 endpoints allow you to watch user presence:
// You need to be watching some channels/queries to be able to get presence events.
// Here are three different ways of doing that:
// 1. Watch a single channel with presence = true set
val watchRequest = WatchChannelRequest().apply {
data["members"] = listOf("john", "jack")
presence = true
}
channelClient.watch(watchRequest).enqueue { result ->
if (result.isSuccess) {
val channel: Channel = result.data()
} else {
// Handle result.error()
}
}
// 2. Query some channels with presence = true set
val channelsRequest = QueryChannelsRequest(
filter = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf("john", "jack")),
),
offset = 0,
limit = 10,
).apply {
presence = true
}
client.queryChannels(channelsRequest).enqueue { result ->
if (result.isSuccess) {
val channels: List<Channel> = result.data()
} else {
// Handle result.error()
}
}
// 3. Query some users with presence = true set
val usersQuery = QueryUsersRequest(
filter = Filters.`in`("id", listOf("john", "jack")),
offset = 0,
limit = 2,
presence = true,
)
client.queryUsers(usersQuery).enqueue { result ->
if (result.isSuccess) {
val users: List<User> = result.data()
} else {
// Handle result.error()
}
}
// Finally, subscribe to presence to events
client.subscribeFor<UserPresenceChangedEvent> { event ->
// Handle change
}
Users' online status change can be handled via event delegation by subscribing to the user.presence.changed
event the same you do for any other event.
You will need to take care of four things:
- Send an event
typing.start
when the user starts typing - Send an event
typing.stop
after the user stopped typing - Handle the two events and use them to toggle the typing indicator UI
- Use
parent_id
field of the event to indicate that typing is happening in a thread
// Sends a typing.start event at most once every two seconds
channelClient.keystroke().enqueue()
// Sends a typing.start event for a particular thread
channelClient.keystroke(parentId = "threadId").enqueue()
// Sends the typing.stop event
channelClient.stopTyping().enqueue()
When sending events on user input, you should make sure to follow some best-practices to avoid bugs.
- Only send
typing.start
when the user starts typing - Send
typing.stop
after a few seconds since the last keystroke
// Add typing start event handling
channelClient.subscribeFor<TypingStartEvent> { typingStartEvent ->
// Handle event
}
// Add typing stop event handling
channelClient.subscribeFor<TypingStopEvent> { typingStopEvent ->
// Handle event
}
Because clients might fail at sending
typing.stop
event all Chat clients periodically prune the list of typing users.
As soon as you call watch
on a Channel or queryChannels
you’ll start to listen to these events. You can hook into specific events:
val channelClient = client.channel("messaging", "channelId")
// Subscribe for new message events
val disposable: Disposable = channelClient.subscribeFor<NewMessageEvent> { newMessageEvent ->
val message = newMessageEvent.message
}
// Dispose when you want to stop receiving events
disposable.dispose()
You can also listen to all events at once:
val disposable: Disposable = channelClient.subscribe { event: ChatEvent ->
when (event) {
// Check for specific event types
is NewMessageEvent -> {
val message = event.message
}
}
}
// Dispose when you want to stop receiving events
disposable.dispose()
Not all events are specific to channels. Events such as the user's status has changed, the users' unread count has changed, and other notifications are sent as client events. These events can be listened to through the client directly:
// Subscribe for User presence events
client.subscribeFor<UserPresenceChangedEvent> { event ->
// Handle change
}
// Subscribe for just the first ConnectedEvent
client.subscribeForSingle<ConnectedEvent> { event ->
// Use event data
val unreadCount = event.me.totalUnreadCount
val unreadChannels = event.me.unreadChannels
}
The official SDKs make sure that a connection to Stream is kept alive at all times and that chat state is recovered when the user's internet connection comes back online. Your application can subscribe to changes to the connection using client events.
client.subscribeFor(
ConnectedEvent::class,
ConnectingEvent::class,
DisconnectedEvent::class,
) { event ->
when (event) {
is ConnectedEvent -> {
// Socket is connected
}
is ConnectingEvent -> {
// Socket is connecting
}
is DisconnectedEvent -> {
// Socket is disconnected
}
}
}
It is a good practice to unregister event handlers once they are not in use anymore. Doing so will save you from performance degradations coming from memory leaks or even from errors and exceptions (i.e. null pointer exceptions)
val disposable: Disposable = client.subscribe { /* ... */ }
disposable.dispose()
By default, logging is disabled. You can enable logs and set a log level when initializing ChatClient
:
val client = ChatClient.Builder("apiKey", context)
.logLevel(ChatLogLevel.ALL)
.build()
If you need to intercept logs, you can also pass in your own ChatLoggerHandler
:
val client = ChatClient.Builder("apiKey", context)
.logLevel(ChatLogLevel.ALL)
.loggerHandler(object : ChatLoggerHandler {
override fun logD(tag: Any, message: String) {
// custom logging
}
...
})
.build()
All SDK log tags have the Chat:
prefix, so you can filter for that those in the logs:
adb logcat com.your.package | grep "Chat:"
Here's a set of useful tags for debugging network communication:
Chat:Http
Chat:Events
Chat:SocketService