Benoit Marty
8 months ago
committed by
Benoit Marty
16 changed files with 532 additions and 1 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
Rendering typing notification |
@ -0,0 +1,82 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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, |
||||
) |
||||
} |
@ -0,0 +1,170 @@
@@ -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, |
||||
) |
||||
} |
Loading…
Reference in new issue