Skip to content

Commit a8e9215

Browse files
committed
Add message info component to display read and delivered status
1 parent 30f08f5 commit a8e9215

File tree

2 files changed

+214
-2
lines changed

2 files changed

+214
-2
lines changed

stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,61 @@
1616

1717
package io.getstream.chat.android.compose.sample.ui.component
1818

19+
import androidx.annotation.StringRes
20+
import androidx.compose.foundation.layout.Arrangement
21+
import androidx.compose.foundation.layout.Column
22+
import androidx.compose.foundation.layout.PaddingValues
23+
import androidx.compose.foundation.layout.Row
24+
import androidx.compose.foundation.layout.fillMaxWidth
25+
import androidx.compose.foundation.layout.size
26+
import androidx.compose.foundation.lazy.LazyColumn
27+
import androidx.compose.foundation.lazy.LazyListScope
28+
import androidx.compose.foundation.lazy.itemsIndexed
1929
import androidx.compose.material.icons.Icons
2030
import androidx.compose.material.icons.outlined.Info
2131
import androidx.compose.material3.ExperimentalMaterial3Api
2232
import androidx.compose.material3.ModalBottomSheet
33+
import androidx.compose.material3.Text
2334
import androidx.compose.runtime.Composable
35+
import androidx.compose.runtime.collectAsState
2436
import androidx.compose.runtime.getValue
2537
import androidx.compose.runtime.mutableStateOf
2638
import androidx.compose.runtime.remember
39+
import androidx.compose.runtime.rememberCoroutineScope
2740
import androidx.compose.runtime.setValue
41+
import androidx.compose.ui.Alignment
2842
import androidx.compose.ui.Modifier
2943
import androidx.compose.ui.graphics.vector.rememberVectorPainter
44+
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.text.style.TextOverflow
46+
import androidx.compose.ui.tooling.preview.Preview
47+
import androidx.compose.ui.unit.dp
48+
import io.getstream.chat.android.client.ChatClient
49+
import io.getstream.chat.android.client.extensions.deliveredReadsOf
50+
import io.getstream.chat.android.client.extensions.readsOf
3051
import io.getstream.chat.android.compose.sample.R
52+
import io.getstream.chat.android.compose.state.DateFormatType
3153
import io.getstream.chat.android.compose.state.messageoptions.MessageOptionItemState
54+
import io.getstream.chat.android.compose.ui.components.Timestamp
55+
import io.getstream.chat.android.compose.ui.components.avatar.Avatar
3256
import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory
3357
import io.getstream.chat.android.compose.ui.theme.ChatTheme
58+
import io.getstream.chat.android.models.Channel
59+
import io.getstream.chat.android.models.ChannelUserRead
3460
import io.getstream.chat.android.models.Message
61+
import io.getstream.chat.android.models.User
62+
import io.getstream.chat.android.state.extensions.watchChannelAsState
3563
import io.getstream.chat.android.ui.common.state.messages.CustomAction
3664
import io.getstream.chat.android.ui.common.state.messages.MessageAction
65+
import io.getstream.chat.android.ui.common.utils.extensions.initials
66+
import kotlinx.coroutines.CoroutineScope
67+
import kotlinx.coroutines.flow.Flow
68+
import kotlinx.coroutines.flow.filterNotNull
69+
import kotlinx.coroutines.flow.flatMapLatest
70+
import kotlinx.coroutines.flow.map
71+
import java.util.Calendar
72+
import java.util.Date
73+
import kotlin.time.Duration.Companion.hours
3774

3875
/**
3976
* Factory for creating components related to message info.
@@ -64,7 +101,7 @@ class MessageInfoComponentFactory : ChatComponentFactory {
64101
iconPainter = rememberVectorPainter(Icons.Outlined.Info),
65102
iconColor = ChatTheme.colors.textLowEmphasis,
66103
action = CustomAction(message, mapOf("message_info" to true)),
67-
)
104+
),
68105
) + messageOptions
69106

70107
val extendedOnMessageAction: (MessageAction) -> Unit = { action ->
@@ -87,7 +124,15 @@ class MessageInfoComponentFactory : ChatComponentFactory {
87124
},
88125
containerColor = ChatTheme.colors.appBackground,
89126
) {
90-
// TODO Replace with a proper Message Info Screen
127+
val coroutineScope = rememberCoroutineScope()
128+
val state by readsOf(message, coroutineScope).collectAsState(null)
129+
state?.let {
130+
val (reads, deliveredReads) = it
131+
MessageInfoContent(
132+
reads = reads,
133+
deliveredReads = deliveredReads,
134+
)
135+
}
91136
}
92137
} else if (!dismissed) {
93138
super.MessageMenu(
@@ -101,4 +146,167 @@ class MessageInfoComponentFactory : ChatComponentFactory {
101146
)
102147
}
103148
}
149+
150+
@Composable
151+
private fun readsOf(
152+
message: Message,
153+
coroutineScope: CoroutineScope,
154+
): Flow<Pair<List<ChannelUserRead>, List<ChannelUserRead>>> = ChatClient.instance()
155+
.watchChannelAsState(
156+
cid = message.cid,
157+
messageLimit = 0,
158+
coroutineScope = coroutineScope,
159+
).filterNotNull()
160+
.flatMapLatest { it.reads }
161+
.map {
162+
val channel = Channel(read = it)
163+
164+
val reads = channel.readsOf(message)
165+
.sortedByDescending(ChannelUserRead::lastRead)
166+
167+
val deliveredReads = channel.deliveredReadsOf(message)
168+
.sortedByDescending { it.lastDeliveredAt ?: Date(0) } - reads
169+
170+
reads to deliveredReads
171+
}
172+
}
173+
174+
@Composable
175+
private fun MessageInfoContent(
176+
reads: List<ChannelUserRead>,
177+
deliveredReads: List<ChannelUserRead>,
178+
) {
179+
LazyColumn(
180+
modifier = Modifier.fillMaxWidth(),
181+
contentPadding = PaddingValues(
182+
start = 16.dp,
183+
end = 16.dp,
184+
bottom = 16.dp,
185+
),
186+
) {
187+
// Read by section
188+
section(
189+
items = reads,
190+
labelResId = R.string.message_info_read_by,
191+
skipTopPadding = true,
192+
)
193+
// Delivered to section
194+
section(
195+
items = deliveredReads,
196+
labelResId = R.string.message_info_delivered_to,
197+
)
198+
}
199+
}
200+
201+
private fun LazyListScope.section(
202+
items: List<ChannelUserRead>,
203+
@StringRes labelResId: Int,
204+
skipTopPadding: Boolean = false,
205+
) {
206+
if (items.isNotEmpty()) {
207+
item {
208+
if (skipTopPadding) {
209+
PaneTitle(
210+
text = stringResource(labelResId, items.size),
211+
padding = PaddingValues(
212+
start = 16.dp,
213+
bottom = 8.dp,
214+
end = 16.dp,
215+
),
216+
)
217+
} else {
218+
PaneTitle(text = stringResource(labelResId, items.size))
219+
}
220+
}
221+
itemsIndexed(
222+
items = items,
223+
key = { _, item -> item.user.id },
224+
) { index, item ->
225+
PaneRow(
226+
index = index,
227+
lastIndex = items.lastIndex,
228+
) {
229+
ReadItem(userRead = item)
230+
}
231+
}
232+
}
233+
}
234+
235+
@Composable
236+
private fun ReadItem(
237+
userRead: ChannelUserRead,
238+
) {
239+
Row(
240+
horizontalArrangement = Arrangement.spacedBy(12.dp),
241+
verticalAlignment = Alignment.CenterVertically,
242+
) {
243+
Avatar(
244+
modifier = Modifier.size(48.dp),
245+
imageUrl = userRead.user.image,
246+
initials = userRead.user.initials,
247+
contentDescription = userRead.user.name,
248+
)
249+
250+
Column(
251+
verticalArrangement = Arrangement.spacedBy(4.dp),
252+
) {
253+
Text(
254+
text = userRead.user.name.takeIf(String::isNotBlank) ?: userRead.user.id,
255+
style = ChatTheme.typography.bodyBold,
256+
color = ChatTheme.colors.textHighEmphasis,
257+
maxLines = 1,
258+
overflow = TextOverflow.Ellipsis,
259+
)
260+
261+
Timestamp(
262+
date = userRead.lastDeliveredAt ?: userRead.lastRead,
263+
formatType = DateFormatType.RELATIVE,
264+
)
265+
}
266+
}
267+
}
268+
269+
@Suppress("MagicNumber")
270+
@Preview
271+
@Composable
272+
private fun MessageInfoScreenPreview() {
273+
val sentDate = Calendar.getInstance().apply {
274+
set(2025, Calendar.AUGUST, 15, 8, 15)
275+
}.time
276+
val user1 = User(id = "jane", name = "Jane Doe")
277+
val user2 = User(id = "bob", name = "Bob Smith")
278+
val user3 = User(id = "alice", name = "Alice Johnson")
279+
val reads = listOf(
280+
ChannelUserRead(
281+
user = user1,
282+
lastReceivedEventDate = Date(),
283+
unreadMessages = 0,
284+
lastRead = sentDate.apply { time += 2.hours.inWholeMilliseconds },
285+
lastReadMessageId = null,
286+
),
287+
ChannelUserRead(
288+
user = user2,
289+
lastReceivedEventDate = Date(),
290+
unreadMessages = 0,
291+
lastRead = sentDate.apply { time += 3.hours.inWholeMilliseconds },
292+
lastReadMessageId = null,
293+
),
294+
)
295+
val deliveredReads = listOf(
296+
ChannelUserRead(
297+
user = user3,
298+
lastReceivedEventDate = Date(),
299+
unreadMessages = 0,
300+
lastRead = Date(),
301+
lastReadMessageId = null,
302+
lastDeliveredAt = sentDate.apply { time += 1.hours.inWholeMilliseconds },
303+
lastDeliveredMessageId = null,
304+
),
305+
)
306+
ChatTheme {
307+
MessageInfoContent(
308+
deliveredReads = deliveredReads,
309+
reads = reads,
310+
)
311+
}
104312
}

stream-chat-android-compose-sample/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,8 @@
128128
<string name="channel_attachments_media_loading_more_error">Failed to load more media attachments</string>
129129
<string name="channel_attachments_files_loading_more_error">Failed to load more files attachments</string>
130130

131+
<!-- Message Info -->
132+
<string name="message_info_read_by">READ BY (%d)</string>
133+
<string name="message_info_delivered_to">DELIVERY TO (%d)</string>
134+
131135
</resources>

0 commit comments

Comments
 (0)