From 5d6716da6786040109e7c3790ad5cefb8ef1b208 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jan 2024 14:33:07 +0100 Subject: [PATCH] Rendering typing notification #2242 --- changelog.d/2242.feature | 1 + .../messages/impl/MessagesPresenter.kt | 4 + .../features/messages/impl/MessagesState.kt | 2 + .../messages/impl/MessagesStateProvider.kt | 2 + .../features/messages/impl/MessagesView.kt | 1 + .../messages/impl/timeline/TimelineView.kt | 8 + .../typing/TypingNotificationPresenter.kt | 82 +++++++++ .../impl/typing/TypingNotificationState.kt | 24 +++ .../typing/TypingNotificationStateProvider.kt | 95 ++++++++++ .../impl/typing/TypingNotificationView.kt | 103 +++++++++++ .../messages/impl/MessagesPresenterTest.kt | 3 + .../typing/TypingNotificationPresenterTest.kt | 170 ++++++++++++++++++ .../libraries/matrix/api/room/MatrixRoom.kt | 1 + .../libraries/matrix/api/room/RoomMember.kt | 14 +- .../matrix/impl/room/RustMatrixRoom.kt | 16 ++ .../matrix/test/room/FakeMatrixRoom.kt | 7 + 16 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2242.feature create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt diff --git a/changelog.d/2242.feature b/changelog.d/2242.feature new file mode 100644 index 0000000000..947661653f --- /dev/null +++ b/changelog.d/2242.feature @@ -0,0 +1 @@ +Rendering typing notification diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 1b264ac1f7..986dba4709 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 8e4d1c484b..5c6e1c7ffa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 9e36086c45..da278904a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 1c1bca9179..e629571c3c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 72cf2be504..410e74faa4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt new file mode 100644 index 0000000000..fdbb698a02 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt @@ -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 { + @Composable + override fun present(): TypingNotificationState { + var typingMembers by remember { mutableStateOf(emptyList()) } + 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, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt new file mode 100644 index 0000000000..d4398a6351 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt @@ -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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt new file mode 100644 index 0000000000..88a3c8677f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt @@ -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 { + override val values: Sequence + 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 = 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, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt new file mode 100644 index 0000000000..7ad31abd9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt @@ -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): 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, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index fe7e07e6f9..9e729e32e1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt new file mode 100644 index 0000000000..4bedd6f6a3 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -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, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 051e0e419e..db5cd6c260 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -57,6 +57,7 @@ interface MatrixRoom : Closeable { val isDm: Boolean get() = isDirect && isOneToOne val roomInfoFlow: Flow + val roomTypingMembersFlow: Flow> /** * A one-to-one is a room with exactly 2 members. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 86cff93688..908390484b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -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, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 36b6cde936..65c648e5e2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -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> = mxCallbackFlow { + launch { + val initial = emptyList() + channel.trySend(initial) + } + innerRoom.subscribeToTypingNotifications(object : TypingNotificationsListener { + override fun call(typingUsers: List) { + channel.trySend( + typingUsers + .filter { it != sessionData.userId } + .map(::UserId)) + } + }) + } + // Create a dispatcher for all room methods... private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index fdd0dbb6cb..60969cfd0b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -170,6 +170,9 @@ class FakeMatrixRoom( private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1) override val roomInfoFlow: Flow = _roomInfoFlow + private val _roomTypingMembersFlow: MutableSharedFlow> = MutableSharedFlow(replay = 1) + override val roomTypingMembersFlow: Flow> = _roomTypingMembersFlow + override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) override val roomNotificationSettingsStateFlow: MutableStateFlow = @@ -589,6 +592,10 @@ class FakeMatrixRoom( fun givenRoomInfo(roomInfo: MatrixRoomInfo) { _roomInfoFlow.tryEmit(roomInfo) } + + fun givenRoomTypingMembers(typingMembers: List) { + _roomTypingMembersFlow.tryEmit(typingMembers) + } } data class SendLocationInvocation(