Rendering typing notification #2242
This commit is contained in:
committed by
Benoit Marty
parent
e73a480495
commit
8496c736b6
1
changelog.d/2242.feature
Normal file
1
changelog.d/2242.feature
Normal file
@@ -0,0 +1 @@
|
||||
Rendering typing notification
|
||||
@@ -59,6 +59,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
@@ -97,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val composerPresenter: MessageComposerPresenter,
|
||||
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
|
||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||
private val typingNotificationPresenter: TypingNotificationPresenter,
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
private val customReactionPresenter: CustomReactionPresenter,
|
||||
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
||||
@@ -129,6 +131,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val composerState = composerPresenter.present()
|
||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||
val timelineState = timelinePresenter.present()
|
||||
val typingNotificationState = typingNotificationPresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
@@ -233,6 +236,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
composerState = composerState,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
typingNotificationState = typingNotificationState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
|
||||
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
@@ -42,6 +43,7 @@ data class MessagesState(
|
||||
val composerState: MessageComposerState,
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val typingNotificationState: TypingNotificationState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
|
||||
@@ -117,6 +118,7 @@ fun aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
),
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
retrySendMenuState = RetrySendMenuState(
|
||||
selectedEvent = null,
|
||||
eventSink = {},
|
||||
|
||||
@@ -384,6 +384,7 @@ private fun MessagesViewContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
state = state.timelineState,
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
typingNotificationState = state.typingNotificationState,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
|
||||
@@ -62,6 +62,9 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationView
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.libraries.designsystem.animation.alphaAnimation
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -75,6 +78,7 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
state: TimelineState,
|
||||
typingNotificationState: TypingNotificationState,
|
||||
roomName: String?,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
@@ -112,6 +116,9 @@ fun TimelineView(
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
@@ -256,6 +263,7 @@ internal fun TimelineViewPreview(
|
||||
TimelineView(
|
||||
state = aTimelineState(timelineItems),
|
||||
roomName = null,
|
||||
typingNotificationState = aTypingNotificationState(),
|
||||
onMessageClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onUserDataClicked = {},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
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 io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
class TypingNotificationPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) : Presenter<TypingNotificationState> {
|
||||
@Composable
|
||||
override fun present(): TypingNotificationState {
|
||||
var typingMembers by remember { mutableStateOf(emptyList<RoomMember>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
|
||||
typingMembers
|
||||
.map { userId ->
|
||||
membersState.roomMembers()
|
||||
?.firstOrNull { roomMember -> roomMember.userId == userId }
|
||||
?: createDefaultRoomMemberForTyping(userId)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { members ->
|
||||
typingMembers = members
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
return TypingNotificationState(
|
||||
typingMembers = typingMembers.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default [RoomMember] for typing events.
|
||||
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
|
||||
* as close as possible to the actual data.
|
||||
*/
|
||||
private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
|
||||
return RoomMember(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class TypingNotificationState(
|
||||
val typingMembers: ImmutableList<RoomMember>,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
|
||||
override val values: Sequence<TypingNotificationState>
|
||||
get() = sequenceOf(
|
||||
aTypingNotificationState(),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
aTypingRoomMember(displayName = "Charlie"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
aTypingRoomMember(displayName = "Charlie"),
|
||||
aTypingRoomMember(displayName = "Dan"),
|
||||
aTypingRoomMember(displayName = "Eve"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice with a very long display name"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTypingNotificationState(
|
||||
typingMembers: List<RoomMember> = emptyList(),
|
||||
) = TypingNotificationState(
|
||||
typingMembers = typingMembers.toImmutableList(),
|
||||
)
|
||||
|
||||
internal fun aTypingRoomMember(
|
||||
userId: UserId = UserId("@alice:example.com"),
|
||||
displayName: String? = null,
|
||||
isNameAmbiguous: Boolean = false,
|
||||
): RoomMember {
|
||||
return RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = isNameAmbiguous,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
@Composable
|
||||
fun TypingNotificationView(
|
||||
state: TypingNotificationState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.typingMembers.isEmpty()) return
|
||||
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
|
||||
Text(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 68.dp, vertical = 2.dp),
|
||||
text = typingNotificationText,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun computeTypingNotificationText(typingMembers: List<RoomMember>): AnnotatedString {
|
||||
val names = when (typingMembers.size) {
|
||||
0 -> "" // Cannot happen
|
||||
1 -> typingMembers[0].disambiguatedDisplayName
|
||||
2 -> stringResource(
|
||||
id = R.string.screen_room_typing_two_members,
|
||||
typingMembers[0].disambiguatedDisplayName,
|
||||
typingMembers[1].disambiguatedDisplayName,
|
||||
)
|
||||
else -> pluralStringResource(
|
||||
id = R.plurals.screen_room_typing_many_members,
|
||||
count = typingMembers.size - 2,
|
||||
typingMembers[0].disambiguatedDisplayName,
|
||||
typingMembers[1].disambiguatedDisplayName,
|
||||
typingMembers.size - 2,
|
||||
)
|
||||
}
|
||||
// Get the translated string with a fake pattern
|
||||
val tmpString = pluralStringResource(
|
||||
id = R.plurals.screen_room_typing_notification,
|
||||
count = typingMembers.size,
|
||||
"<>",
|
||||
)
|
||||
// Split the string in 3 parts
|
||||
val parts = tmpString.split("<>")
|
||||
// And rebuild the string with the names
|
||||
return buildAnnotatedString {
|
||||
append(parts[0])
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(names)
|
||||
}
|
||||
append(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TypingNotificationViewPreview(
|
||||
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
|
||||
) = ElementPreview {
|
||||
TypingNotificationView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
@@ -730,6 +731,7 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore)
|
||||
val typingNotificationPresenter = TypingNotificationPresenter(matrixRoom)
|
||||
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
|
||||
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
||||
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
||||
@@ -739,6 +741,7 @@ class MessagesPresenterTest {
|
||||
composerPresenter = messageComposerPresenter,
|
||||
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
|
||||
timelinePresenterFactory = timelinePresenterFactory,
|
||||
typingNotificationPresenter = typingNotificationPresenter,
|
||||
actionListPresenter = actionListPresenter,
|
||||
customReactionPresenter = customReactionPresenter,
|
||||
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_4
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class TypingNotificationPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.typingMembers).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when a member is typing, member is not known`() = runTest {
|
||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.typingMembers).isEmpty()
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
|
||||
// User stops typing
|
||||
room.givenRoomTypingMembers(emptyList())
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.typingMembers).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when a member is typing, member is known`() = runTest {
|
||||
val aKnownRoomMember = createKnownRoomMember(userId = A_USER_ID_2)
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(
|
||||
MatrixRoomMembersState.Ready(
|
||||
listOf(
|
||||
createKnownRoomMember(A_USER_ID),
|
||||
aKnownRoomMember,
|
||||
createKnownRoomMember(A_USER_ID_3),
|
||||
createKnownRoomMember(A_USER_ID_4),
|
||||
).toImmutableList()
|
||||
)
|
||||
)
|
||||
}
|
||||
val presenter = createPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.typingMembers).isEmpty()
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
|
||||
// User stops typing
|
||||
room.givenRoomTypingMembers(emptyList())
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.typingMembers).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when a member is typing, member is not known, then known`() = runTest {
|
||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
||||
val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.typingMembers).isEmpty()
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
|
||||
// User is getting known
|
||||
room.givenRoomMembersState(
|
||||
MatrixRoomMembersState.Ready(
|
||||
listOf(aKnownRoomMember).toImmutableList()
|
||||
)
|
||||
)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
|
||||
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
|
||||
},
|
||||
): TypingNotificationPresenter {
|
||||
return TypingNotificationPresenter(
|
||||
room = matrixRoom,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDefaultRoomMember(
|
||||
userId: UserId,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
|
||||
private fun createKnownRoomMember(
|
||||
userId: UserId,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = "Alice Doe",
|
||||
avatarUrl = "an_avatar_url",
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = true,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ interface MatrixRoom : Closeable {
|
||||
val isDm: Boolean get() = isDirect && isOneToOne
|
||||
|
||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||
|
||||
/**
|
||||
* A one-to-one is a room with exactly 2 members.
|
||||
|
||||
@@ -27,7 +27,19 @@ data class RoomMember(
|
||||
val powerLevel: Long,
|
||||
val normalizedPowerLevel: Long,
|
||||
val isIgnored: Boolean,
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Disambiguated display name for the RoomMember.
|
||||
* If the display name is null, the user ID is returned.
|
||||
* If the display name is ambiguous, the user ID is appended in parentheses.
|
||||
* Otherwise, the display name is returned.
|
||||
*/
|
||||
val disambiguatedDisplayName: String = when {
|
||||
displayName == null -> userId.value
|
||||
isNameAmbiguous -> "$displayName ($userId)"
|
||||
else -> displayName
|
||||
}
|
||||
}
|
||||
|
||||
enum class RoomMembershipState {
|
||||
BAN,
|
||||
|
||||
@@ -73,6 +73,7 @@ import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
@@ -113,6 +114,21 @@ class RustMatrixRoom(
|
||||
})
|
||||
}
|
||||
|
||||
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
|
||||
launch {
|
||||
val initial = emptyList<UserId>()
|
||||
channel.trySend(initial)
|
||||
}
|
||||
innerRoom.subscribeToTypingNotifications(object : TypingNotificationsListener {
|
||||
override fun call(typingUsers: List<String>) {
|
||||
channel.trySend(
|
||||
typingUsers
|
||||
.filter { it != sessionData.userId }
|
||||
.map(::UserId))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create a dispatcher for all room methods...
|
||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||
|
||||
|
||||
@@ -170,6 +170,9 @@ class FakeMatrixRoom(
|
||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||
|
||||
private val _roomTypingMembersFlow: MutableSharedFlow<List<UserId>> = MutableSharedFlow(replay = 1)
|
||||
override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow
|
||||
|
||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||
|
||||
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
||||
@@ -589,6 +592,10 @@ class FakeMatrixRoom(
|
||||
fun givenRoomInfo(roomInfo: MatrixRoomInfo) {
|
||||
_roomInfoFlow.tryEmit(roomInfo)
|
||||
}
|
||||
|
||||
fun givenRoomTypingMembers(typingMembers: List<UserId>) {
|
||||
_roomTypingMembersFlow.tryEmit(typingMembers)
|
||||
}
|
||||
}
|
||||
|
||||
data class SendLocationInvocation(
|
||||
|
||||
Reference in New Issue
Block a user