[Chat] [Feature] Chat Screen typing indicator (#503)
This commit is contained in:
Родитель
e353288bf5
Коммит
1a1941b8d5
|
@ -4,11 +4,8 @@
|
|||
package com.azure.android.communication.ui.chat.presentation.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
@ -20,63 +17,39 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.azure.android.communication.ui.chat.models.RemoteParticipantInfoModel
|
||||
import com.azure.android.communication.ui.chat.service.sdk.wrapper.CommunicationIdentifier
|
||||
|
||||
@Composable
|
||||
internal fun TypingIndicatorView(participants: Collection<RemoteParticipantInfoModel>) {
|
||||
val typers = participants.filter { it.isTyping }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = typers.isNotEmpty(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
internal fun TypingIndicatorView(typingParticipantsDisplayName: List<String>) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy((-12).dp)
|
||||
) {
|
||||
Box(Modifier.size(width = 0.dp, height = 40.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy((-12).dp)
|
||||
) {
|
||||
Box(Modifier.size(width = 0.dp, height = 40.dp))
|
||||
|
||||
participants.forEach {
|
||||
AnimatedVisibility(
|
||||
visible = it.isTyping,
|
||||
enter = expandHorizontally(), exit = shrinkHorizontally()
|
||||
|
||||
) {
|
||||
AvatarView(name = it.displayName)
|
||||
}
|
||||
typingParticipantsDisplayName.forEach {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = expandHorizontally(), exit = shrinkHorizontally()
|
||||
) {
|
||||
AvatarView(name = it)
|
||||
}
|
||||
}
|
||||
|
||||
if (typers.isNotEmpty()) {
|
||||
Text(
|
||||
"is Typing",
|
||||
Modifier
|
||||
.padding(start = 15.dp)
|
||||
.align(alignment = Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
if (typingParticipantsDisplayName.isNotEmpty()) {
|
||||
Text(
|
||||
"is Typing",
|
||||
Modifier
|
||||
.padding(start = 15.dp)
|
||||
.align(alignment = Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@ExperimentalAnimationApi
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun PreviewTypingIndicatorView() {
|
||||
TypingIndicatorView(
|
||||
participants = listOf(
|
||||
RemoteParticipantInfoModel(
|
||||
CommunicationIdentifier.CommunicationUserIdentifier(""),
|
||||
displayName = "User A",
|
||||
isTyping = true
|
||||
),
|
||||
RemoteParticipantInfoModel(
|
||||
CommunicationIdentifier.CommunicationUserIdentifier(""),
|
||||
displayName = "User B",
|
||||
isTyping = true
|
||||
),
|
||||
)
|
||||
typingParticipantsDisplayName = listOf("User A", "User B",),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ internal fun ChatScreen(
|
|||
dispatcher?.onBackPressed()
|
||||
}
|
||||
},
|
||||
content = {
|
||||
content = { paddingValues ->
|
||||
if (viewModel.showError) {
|
||||
Column {
|
||||
BasicText("ERROR")
|
||||
|
@ -56,15 +56,13 @@ internal fun ChatScreen(
|
|||
CircularProgressIndicator()
|
||||
} else {
|
||||
MessageListView(
|
||||
modifier = Modifier.padding(it),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
messages = viewModel.messages,
|
||||
scrollState = LazyListState(),
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.participants.also { remoteParticipants ->
|
||||
TypingIndicatorView(participants = remoteParticipants.values)
|
||||
}
|
||||
TypingIndicatorView(viewModel.typingParticipants.toList())
|
||||
},
|
||||
bottomBar = {
|
||||
BottomBarView(
|
||||
|
@ -82,7 +80,7 @@ internal fun ChatScreenPreview() {
|
|||
ChatCompositeTheme {
|
||||
ChatScreen(
|
||||
viewModel = ChatScreenViewModel(
|
||||
listOf(
|
||||
messages = listOf(
|
||||
MessageViewModel(
|
||||
MessageInfoModel(
|
||||
messageType = ChatMessageType.TEXT,
|
||||
|
@ -116,6 +114,7 @@ internal fun ChatScreenPreview() {
|
|||
),
|
||||
chatStatus = ChatStatus.INITIALIZED,
|
||||
buildCount = 2,
|
||||
typingParticipants = setOf("John Doe", "Mary Sue"),
|
||||
postAction = {},
|
||||
participants = listOf(
|
||||
RemoteParticipantInfoModel(
|
||||
|
@ -143,7 +142,8 @@ internal fun ChatScreenPreview() {
|
|||
// error = ChatStateError(
|
||||
// errorCode = ErrorCode.CHAT_JOIN_FAILED
|
||||
// )
|
||||
)
|
||||
) {},
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,22 +4,27 @@
|
|||
package com.azure.android.communication.ui.chat.presentation.ui.viewmodel
|
||||
|
||||
import com.azure.android.communication.ui.chat.error.ChatStateError
|
||||
import com.azure.android.communication.ui.chat.models.MessageInfoModel
|
||||
import com.azure.android.communication.ui.chat.models.RemoteParticipantInfoModel
|
||||
import com.azure.android.communication.ui.chat.redux.AppStore
|
||||
import com.azure.android.communication.ui.chat.redux.action.Action
|
||||
import com.azure.android.communication.ui.chat.redux.action.ChatAction
|
||||
import com.azure.android.communication.ui.chat.redux.state.ChatStatus
|
||||
|
||||
import com.azure.android.communication.ui.chat.redux.state.ReduxState
|
||||
import com.azure.android.communication.ui.chat.service.sdk.wrapper.ChatMessageType
|
||||
import com.azure.android.communication.ui.chat.repository.MessageRepositoryView
|
||||
|
||||
// View Model for the Chat Screen
|
||||
internal data class ChatScreenViewModel(
|
||||
val typingParticipants: Set<String>,
|
||||
val messages: List<MessageViewModel>,
|
||||
val chatStatus: ChatStatus,
|
||||
var buildCount: Int,
|
||||
val postAction: (Action) -> Unit,
|
||||
private val error: ChatStateError? = null,
|
||||
val participants: Map<String, RemoteParticipantInfoModel>,
|
||||
val postMessage: (String) -> Unit
|
||||
) {
|
||||
val showError get() = error != null
|
||||
val errorMessage get() = error?.errorCode?.toString() ?: ""
|
||||
|
@ -43,9 +48,21 @@ internal fun buildChatScreenViewModel(
|
|||
chatStatus = store.getCurrentState().chatState.chatStatus,
|
||||
buildCount = buildCount++,
|
||||
error = store.getCurrentState().errorState.chatStateError,
|
||||
typingParticipants = store.getCurrentState().participantState.participantTyping,
|
||||
postAction = dispatchers!!::postAction,
|
||||
participants = store.getCurrentState().participantState.participants,
|
||||
)
|
||||
) { message ->
|
||||
store.dispatch(
|
||||
ChatAction.SendMessage(
|
||||
MessageInfoModel(
|
||||
id = null,
|
||||
messageType = ChatMessageType.TEXT,
|
||||
internalId = null,
|
||||
content = message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal var dispatchers: Dispatchers? = null
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
|
||||
package com.azure.android.communication.ui.chat.redux.action
|
||||
|
||||
import com.azure.android.communication.ui.chat.models.ParticipantTimestampInfoModel
|
||||
import com.azure.android.communication.ui.chat.models.RemoteParticipantInfoModel
|
||||
|
||||
internal sealed class ParticipantAction : Action {
|
||||
class ParticipantsAdded(val participants: List<RemoteParticipantInfoModel>) :
|
||||
ParticipantAction()
|
||||
|
||||
class ParticipantsRemoved(val participants: List<RemoteParticipantInfoModel>) :
|
||||
ParticipantAction()
|
||||
class TypingIndicatorReceived(val message: ParticipantTimestampInfoModel) : ParticipantAction()
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ internal class ChatServiceListener(
|
|||
is ParticipantTimestampInfoModel -> {
|
||||
when (it.eventType) {
|
||||
ChatEventType.TYPING_INDICATOR_RECEIVED -> {
|
||||
val model = it.infoModel
|
||||
dispatch(ParticipantAction.TypingIndicatorReceived(message = it.infoModel))
|
||||
}
|
||||
ChatEventType.READ_RECEIPT_RECEIVED -> {
|
||||
val model = it.infoModel
|
||||
|
|
|
@ -10,15 +10,20 @@ import com.azure.android.communication.ui.chat.redux.state.ParticipantsState
|
|||
internal interface ParticipantsReducer : Reducer<ParticipantsState>
|
||||
|
||||
internal class ParticipantsReducerImpl : ParticipantsReducer {
|
||||
override fun reduce(state: ParticipantsState, action: Action): ParticipantsState {
|
||||
return when (action) {
|
||||
override fun reduce(state: ParticipantsState, action: Action): ParticipantsState =
|
||||
when (action) {
|
||||
is ParticipantAction.ParticipantsAdded -> {
|
||||
state.copy(participants = state.participants + action.participants.associateBy({ it.userIdentifier.id }))
|
||||
state.copy(participants = state.participants + action.participants.associateBy { it.userIdentifier.id })
|
||||
}
|
||||
is ParticipantAction.ParticipantsRemoved -> {
|
||||
state.copy(participants = state.participants - action.participants.map { it.userIdentifier.id })
|
||||
}
|
||||
is ParticipantAction.TypingIndicatorReceived -> {
|
||||
val participantsTyping = HashSet<String>()
|
||||
participantsTyping.addAll(state.participantTyping)
|
||||
participantsTyping.add(action.message.userIdentifier.id)
|
||||
state.copy(participantTyping = participantsTyping)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,10 @@ internal class AppReduxState(
|
|||
)
|
||||
)
|
||||
|
||||
override var participantState: ParticipantsState = ParticipantsState(participants = mapOf())
|
||||
override var participantState: ParticipantsState = ParticipantsState(
|
||||
participants = mapOf(),
|
||||
participantTyping = setOf()
|
||||
)
|
||||
|
||||
override var lifecycleState: LifecycleState = LifecycleState(LifecycleStatus.FOREGROUND)
|
||||
|
||||
|
|
|
@ -7,4 +7,5 @@ import com.azure.android.communication.ui.chat.models.RemoteParticipantInfoModel
|
|||
|
||||
internal data class ParticipantsState(
|
||||
val participants: Map<String, RemoteParticipantInfoModel>,
|
||||
val participantTyping: Set<String>
|
||||
)
|
||||
|
|
|
@ -36,7 +36,8 @@ class ParticipantsReducerUnitTest {
|
|||
// arrange
|
||||
val reducer = ParticipantsReducerImpl()
|
||||
val previousState = ParticipantsState(
|
||||
participants = listOf(userOne, userTwo).associateBy({ it.userIdentifier.id })
|
||||
participants = listOf(userOne, userTwo).associateBy { it.userIdentifier.id },
|
||||
participantTyping = hashSetOf(userOne.displayName!!, userTwo.displayName!!)
|
||||
)
|
||||
val action = ParticipantAction.ParticipantsAdded(participants = listOf(userThree, userFour))
|
||||
|
||||
|
@ -55,7 +56,8 @@ class ParticipantsReducerUnitTest {
|
|||
// arrange
|
||||
val reducer = ParticipantsReducerImpl()
|
||||
val previousState = ParticipantsState(
|
||||
participants = listOf(userOne, userTwo).associateBy({ it.userIdentifier.id })
|
||||
participants = listOf(userOne, userTwo).associateBy { it.userIdentifier.id },
|
||||
participantTyping = hashSetOf(userOne.displayName!!, userTwo.displayName!!)
|
||||
)
|
||||
val userTwo_duplicate = RemoteParticipantInfoModel(
|
||||
userIdentifier = CommunicationIdentifier.UnknownIdentifier("931804B1-D72E-4E70-BFEA-7813C7761BD2"),
|
||||
|
@ -84,7 +86,8 @@ class ParticipantsReducerUnitTest {
|
|||
// arrange
|
||||
val reducer = ParticipantsReducerImpl()
|
||||
val previousState = ParticipantsState(
|
||||
participants = listOf(userOne, userTwo).associateBy({ it.userIdentifier.id })
|
||||
participants = listOf(userOne, userTwo).associateBy { it.userIdentifier.id },
|
||||
participantTyping = hashSetOf(userOne.displayName!!, userTwo.displayName!!)
|
||||
)
|
||||
val userOne_duplicate = RemoteParticipantInfoModel(
|
||||
userIdentifier = CommunicationIdentifier.UnknownIdentifier("7A13DD2C-B49F-4521-9364-975F12F6E333"),
|
||||
|
@ -113,12 +116,9 @@ class ParticipantsReducerUnitTest {
|
|||
// arrange
|
||||
val reducer = ParticipantsReducerImpl()
|
||||
val previousState = ParticipantsState(
|
||||
participants = listOf(
|
||||
userOne,
|
||||
userTwo,
|
||||
userThree,
|
||||
userFour
|
||||
).associateBy({ it.userIdentifier.id })
|
||||
|
||||
participants = listOf(userOne, userTwo, userThree, userFour).associateBy { it.userIdentifier.id },
|
||||
participantTyping = hashSetOf(userOne.displayName!!, userTwo.displayName!!)
|
||||
)
|
||||
val action =
|
||||
ParticipantAction.ParticipantsRemoved(participants = listOf(userThree, userFour))
|
||||
|
@ -129,7 +129,7 @@ class ParticipantsReducerUnitTest {
|
|||
// assert
|
||||
Assert.assertEquals(
|
||||
newState.participants,
|
||||
listOf(userOne, userTwo).associateBy({ it.userIdentifier.id })
|
||||
listOf(userOne, userTwo).associateBy { it.userIdentifier.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче