[Chat] [Feature] Chat Screen typing indicator (#503)

This commit is contained in:
v-loalbert 2022-10-18 10:14:39 -07:00 коммит произвёл GitHub
Родитель e353288bf5
Коммит 1a1941b8d5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
9 изменённых файлов: 73 добавлений и 73 удалений

Просмотреть файл

@ -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 }
)
}
}