From d9183e4092e4e8ad46d3403edc89cb8e81e77ef1 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 20 Mar 2023 11:18:25 +0100 Subject: [PATCH] Display most recent activity in room list (#220) * Create `RoomLastMessageFormatter` to produce readable room summaries. * Add unit tests using Robolectric, fix bugs * Add changelog * Move `RoomLastMessageFormatter` back to `impl` module, allow it to receive an `EventTimelineItem` instead of `MessageContent`. --- changelog.d/217.bugfix | 1 + features/roomlist/impl/build.gradle.kts | 7 + .../impl/DefaultRoomLastMessageFormatter.kt | 330 ++++++++ .../roomlist/impl/RoomLastMessageFormatter.kt | 23 + .../roomlist/impl/RoomListPresenter.kt | 14 +- .../features/roomlist/impl/RoomListView.kt | 2 - .../impl/components/RoomSummaryRow.kt | 6 +- .../DefaultRoomLastMessageFormatterTests.kt | 757 ++++++++++++++++++ .../impl/FakeRoomLastMessageFormatter.kt | 31 + .../roomlist/impl/RoomListPresenterTests.kt | 21 +- gradle/libs.versions.toml | 1 + ...er.kt => LastMessageTimestampFormatter.kt} | 2 +- ...> DefaultLastMessageTimestampFormatter.kt} | 6 +- ...faultLastMessageTimestampFormatterTest.kt} | 8 +- ...t => FakeLastMessageTimestampFormatter.kt} | 4 +- .../libraries/matrix/api/room/RoomSummary.kt | 3 +- .../matrix/api/room/message/RoomMessage.kt | 3 +- .../impl/room/RoomSummaryDetailsFactory.kt | 10 +- .../impl/room/message/RoomMessageFactory.kt | 16 +- .../android/libraries/matrix/test/TestData.kt | 1 + .../matrix/test/room/RoomSummaryFixture.kt | 80 +- .../src/main/res/values/strings_eax.xml | 15 + .../android/samples/minimal/MainActivity.kt | 3 +- .../android/samples/minimal/RoomListScreen.kt | 15 +- 24 files changed, 1311 insertions(+), 48 deletions(-) create mode 100644 changelog.d/217.bugfix create mode 100644 features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt create mode 100644 features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt create mode 100644 features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt create mode 100644 features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt rename libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/{LastMessageFormatter.kt => LastMessageTimestampFormatter.kt} (94%) rename libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/{DefaultLastMessageFormatter.kt => DefaultLastMessageTimestampFormatter.kt} (93%) rename libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/{DefaultLastMessageFormatterTest.kt => DefaultLastMessageTimestampFormatterTest.kt} (94%) rename libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/{FakeLastMessageFormatter.kt => FakeLastMessageTimestampFormatter.kt} (90%) diff --git a/changelog.d/217.bugfix b/changelog.d/217.bugfix new file mode 100644 index 0000000000..e1de560697 --- /dev/null +++ b/changelog.d/217.bugfix @@ -0,0 +1 @@ +Display most recent activity for each room in room list diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index acabf3c232..e865527b80 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -25,6 +25,12 @@ plugins { android { namespace = "io.element.android.features.roomlist.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -52,6 +58,7 @@ dependencies { testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt new file mode 100644 index 0000000000..98089fa2c7 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt @@ -0,0 +1,330 @@ +/* + * 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.roomlist.impl + +import android.content.Context +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.withStyle +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import timber.log.Timber +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +@ContributesBinding(SessionScope::class) +class DefaultRoomLastMessageFormatter @Inject constructor( + // TODO replace with StringProvider + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, +) : RoomLastMessageFormatter { + + override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + val isOutgoing = event.sender == matrixClient.sessionId + val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value + return when (val content = event.content) { + is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom) + RedactedContent -> { + val message = context.getString(StringR.string.event_redacted) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is StickerContent -> { + content.body + } + is UnableToDecryptContent -> { + val message = context.getString(StringR.string.encryption_information_decryption_error) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is RoomMembershipContent -> { + processRoomMembershipChange(content, senderDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + processProfileChangeContent(content, senderDisplayName, isOutgoing) + } + is StateContent -> { + processRoomStateChange(content, senderDisplayName, isOutgoing) + } + is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { + prefixIfNeeded(context.getString(StringR.string.room_timeline_item_unsupported), senderDisplayName, isDmRoom) + } + } + } + + private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? { + val messageType: MessageType = messageContent.type ?: return null + + val internalMessage = when (messageType) { + // Doesn't need a prefix + is EmoteMessageType -> { + return "- $senderDisplayName ${messageType.body}" + } + is TextMessageType -> { + messageType.body + } + is VideoMessageType -> { + context.getString(StringR.string.sent_a_video) + } + is ImageMessageType -> { + context.getString(StringR.string.sent_an_image) + } + is FileMessageType -> { + context.getString(StringR.string.sent_a_file) + } + is AudioMessageType -> { + context.getString(StringR.string.sent_an_audio_file) + } + UnknownMessageType -> { + context.getString(StringR.string.unknown_message_content_type_error) + } + is NoticeMessageType -> { + messageType.body + } + } + return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom) + } + + private fun processRoomMembershipChange(membershipContent: RoomMembershipContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? { + val userId = membershipContent.userId + val memberIsYou = userId == matrixClient.sessionId + return when (val change = membershipContent.change) { + MembershipChange.JOINED -> if (memberIsYou) { + context.getString(StringR.string.notice_room_join_by_you) + } else { + context.getString(StringR.string.notice_room_join, userId.value) + } + MembershipChange.LEFT -> if (memberIsYou) { + context.getString(StringR.string.notice_room_leave_by_you) + } else { + context.getString(StringR.string.notice_room_leave, userId.value) + } + MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_ban_by_you, userId.value) + } else { + context.getString(StringR.string.notice_room_ban, senderDisplayName, userId.value) + } + MembershipChange.UNBANNED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_unban_by_you, userId.value) + } else { + context.getString(StringR.string.notice_room_unban, senderDisplayName, userId.value) + } + MembershipChange.KICKED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_remove_by_you, userId.value) + } else { + context.getString(StringR.string.notice_room_remove, senderDisplayName, userId.value) + } + MembershipChange.INVITED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_invite_by_you, userId.value) + } else if (memberIsYou) { + context.getString(StringR.string.notice_room_invite_you, senderDisplayName) + } else { + context.getString(StringR.string.notice_room_invite, senderDisplayName, userId.value) + } + MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { + context.getString(StringR.string.notice_room_invite_accepted_by_you) + } else { + context.getString(StringR.string.notice_room_invite_accepted, userId.value) + } + MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { + context.getString(StringR.string.notice_room_reject_by_you) + } else { + context.getString(StringR.string.notice_room_reject, userId.value) + } + MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_third_party_revoked_invite_by_you, userId.value) + } else { + context.getString(StringR.string.notice_room_third_party_revoked_invite, senderDisplayName, userId.value) + } + MembershipChange.KNOCKED -> if (memberIsYou) { + context.getString(StringR.string.notice_room_knock_by_you) + } else { + context.getString(StringR.string.notice_room_knock, userId.value) + } + MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_knock_accepted_by_you, userId.value) + } else { + context.getString(StringR.string.notice_room_knock_accepted, senderDisplayName, userId.value) + } + MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { + context.getString(StringR.string.notice_room_knock_retracted_by_you) + } else { + context.getString(StringR.string.notice_room_knock_retracted, userId.value) + } + MembershipChange.KNOCK_DENIED -> if (senderIsYou) { + context.getString(StringR.string.notice_room_knock_denied_by_you, userId.value) + } else if (memberIsYou) { + context.getString(StringR.string.notice_room_knock_denied_you, senderDisplayName) + } else { + context.getString(StringR.string.notice_room_knock_denied, senderDisplayName, userId.value) + } + else -> { + Timber.v("Filtering timeline item for room membership: $membershipContent") + null + } + } + } + + private fun processRoomStateChange(stateContent: StateContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? { + return when (val content = stateContent.content) { + is OtherState.RoomAvatar -> { + val hasAvatarUrl = content.url != null + when { + senderIsYou && hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_changed_by_you) + senderIsYou && !hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_removed_by_you) + !senderIsYou && hasAvatarUrl -> context.getString(StringR.string.notice_room_avatar_changed, senderDisplayName) + else -> context.getString(StringR.string.notice_room_avatar_removed, senderDisplayName) + } + } + is OtherState.RoomCreate -> { + if (senderIsYou) { + context.getString(StringR.string.notice_room_created_by_you) + } else { + context.getString(StringR.string.notice_room_created, senderDisplayName) + } + } + is OtherState.RoomEncryption -> context.getString(StringR.string.encryption_enabled) + is OtherState.RoomName -> { + val hasRoomName = content.name != null + when { + senderIsYou && hasRoomName -> context.getString(StringR.string.notice_room_name_changed_by_you, content.name) + senderIsYou && !hasRoomName -> context.getString(StringR.string.notice_room_name_removed_by_you) + !senderIsYou && hasRoomName -> context.getString(StringR.string.notice_room_name_changed, senderDisplayName, content.name) + else -> context.getString(StringR.string.notice_room_name_removed, senderDisplayName) + } + } + is OtherState.RoomThirdPartyInvite -> { + if (content.displayName == null) { + Timber.e("RoomThirdPartyInvite undisplayable due to missing name") + return null + } + if (senderIsYou) { + context.getString(StringR.string.notice_room_third_party_invite_by_you, content.displayName) + } else { + context.getString(StringR.string.notice_room_third_party_invite, senderDisplayName, content.displayName) + } + } + is OtherState.RoomTopic -> { + val hasRoomTopic = content.topic != null + when { + senderIsYou && hasRoomTopic -> context.getString(StringR.string.notice_room_topic_changed_by_you, content.topic) + senderIsYou && !hasRoomTopic -> context.getString(StringR.string.notice_room_topic_removed_by_you) + !senderIsYou && hasRoomTopic -> context.getString(StringR.string.notice_room_topic_changed, senderDisplayName, content.topic) + else -> context.getString(StringR.string.notice_room_topic_removed, senderDisplayName) + } + } + else -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + } + } + + private fun processProfileChangeContent( + profileChangeContent: ProfileChangeContent, + senderDisplayName: String, + senderIsYou: Boolean + ): String? = profileChangeContent.run { + val displayNameChanged = displayName != prevDisplayName + val avatarChanged = avatarUrl != prevAvatarUrl + return when { + avatarChanged && displayNameChanged -> { + val message = processProfileChangeContent(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou) + val avatarChangedToo = context.getString(StringR.string.notice_avatar_changed_too) + "$message\n$avatarChangedToo" + } + displayNameChanged -> { + if (displayName != null && prevDisplayName != null) { + if (senderIsYou) { + context.getString(StringR.string.notice_display_name_changed_from_by_you, prevDisplayName, displayName) + } else { + context.getString(StringR.string.notice_display_name_changed_from, senderDisplayName, prevDisplayName, displayName) + } + } else if (displayName != null) { + if (senderIsYou) { + context.getString(StringR.string.notice_display_name_set_by_you, displayName) + } else { + context.getString(StringR.string.notice_display_name_set, senderDisplayName, displayName) + } + } else { + if (senderIsYou) { + context.getString(StringR.string.notice_display_name_removed_by_you, prevDisplayName) + } else { + context.getString(StringR.string.notice_display_name_removed, senderDisplayName, prevDisplayName) + } + } + } + avatarChanged -> { + if (senderIsYou) { + context.getString(StringR.string.notice_avatar_url_changed_by_you) + } else { + context.getString(StringR.string.notice_avatar_url_changed, senderDisplayName) + } + } + else -> null + } + } + + private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) { + message + } else { + prefix(message, senderDisplayName) + } + + private fun prefix(message: String, senderDisplayName: String): AnnotatedString { + return buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(senderDisplayName) + } + append(": ") + append(message) + } + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt new file mode 100644 index 0000000000..bd59d68592 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt @@ -0,0 +1,23 @@ +/* + * 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.roomlist.impl + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +interface RoomLastMessageFormatter { + fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index b93c8c00d7..7d9a62f2d7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -30,15 +30,16 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.parallelMap -import io.element.android.libraries.dateformatter.api.LastMessageFormatter +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.verification.SessionVerificationService -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -52,7 +53,8 @@ private const val extendedRangeSize = 40 class RoomListPresenter @Inject constructor( private val client: MatrixClient, - private val lastMessageFormatter: LastMessageFormatter, + private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, + private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, ) : Presenter { @@ -169,8 +171,10 @@ class RoomListPresenter @Inject constructor( id = roomSummary.identifier(), name = roomSummary.details.name, hasUnread = roomSummary.details.unreadNotificationCount > 0, - timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), - lastMessage = roomSummary.details.lastMessage, + timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage?.let { message -> + roomLastMessageFormatter.processMessageItem(message.event, roomSummary.details.isDirect) + }.orEmpty(), avatarData = avatarData, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 2b8b1828a9..88209c31f6 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -67,8 +67,6 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.ui.model.MatrixUser -import kotlinx.collections.immutable.ImmutableList import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt index 14d9ef9474..0709b41ef2 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -55,6 +56,7 @@ import androidx.compose.ui.unit.sp import com.google.accompanist.placeholder.material.placeholder import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider +import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -133,13 +135,15 @@ internal fun DefaultRoomSummaryRow( overflow = TextOverflow.Ellipsis ) // Last Message + val attributedLastMessage = (room.lastMessage as? AnnotatedString) + ?: AnnotatedString(room.lastMessage.orEmpty().toString()) Text( modifier = Modifier.placeholder( visible = room.isPlaceholder, shape = TextPlaceholderShape, color = ElementTheme.colors.roomListPlaceHolder(), ), - text = room.lastMessage?.toString().orEmpty(), + text = attributedLastMessage, color = MaterialTheme.roomListRoomMessage(), fontSize = 14.sp, maxLines = 1, diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt new file mode 100644 index 0000000000..8b50194d0f --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt @@ -0,0 +1,757 @@ +/* + * 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.roomlist.impl + +import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class DefaultRoomLastMessageFormatterTests { + + private lateinit var context: Context + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var formatter: DefaultRoomLastMessageFormatter + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() as Context + fakeMatrixClient = FakeMatrixClient() + formatter = DefaultRoomLastMessageFormatter(context, fakeMatrixClient) + } + + @Test + @Config(qualifiers = "en") + fun `Redacted content`() { + val expected = "Message removed" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createRoomEvent(false, senderName, RedactedContent) + val result = formatter.processMessageItem(message, isDm) + if (isDm) { + Truth.assertThat(result).isEqualTo(expected) + } else { + Truth.assertThat(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertThat(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + + @Test + @Config(qualifiers = "en") + fun `Sticker content`() { + val body = "body" + val info = ImageInfo(null, null, null, null, null, null, null) + val message = createRoomEvent(false, null, StickerContent(body, info, "url")) + val result = formatter.processMessageItem(message, false) + Truth.assertThat(result).isEqualTo(body) + } + + @Test + @Config(qualifiers = "en") + fun `Unable to decrypt content`() { + val expected = "Decryption error" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val result = formatter.processMessageItem(message, isDm) + if (isDm) { + Truth.assertThat(result).isEqualTo(expected) + } else { + Truth.assertThat(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertThat(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + + @Test + @Config(qualifiers = "en") + fun `FailedToParseMessageLike, FailedToParseState & Unknown content`() { + val expected = "Unsupported event" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + sequenceOf( + FailedToParseMessageLikeContent("", ""), + FailedToParseStateContent("", "", ""), + UnknownContent, + ).forEach { type -> + val message = createRoomEvent(false, senderName, type) + val result = formatter.processMessageItem(message, isDm) + if (isDm) { + Truth.assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected) + } else { + Truth.assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + } + + // region Message contents + + @Test + @Config(qualifiers = "en") + fun `Message contents`() { + val body = "Shared body" + fun createMessageContent(type: MessageType): MessageContent { + return MessageContent(body, null, false, type) + } + val sharedContentMessagesTypes = arrayOf( + TextMessageType(body, null), + VideoMessageType(body, "url", null), + AudioMessageType(body, "url", null), + ImageMessageType(body, "url", null), + FileMessageType(body, "url", null), + NoticeMessageType(body, null), + EmoteMessageType(body, null), + ) + val senderName = "Someone" + val resultsInRoom = mutableListOf>() + val resultsInDm = mutableListOf>() + + // Create messages for all types in DM and Room mode + sequenceOf(false, true).forEach { isDm -> + sharedContentMessagesTypes.forEach { type -> + val content = createMessageContent(type) + val message = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.processMessageItem(message, isDmRoom = isDm) + if (isDm) { + resultsInDm.add(type to result) + } else { + resultsInRoom.add(type to result) + } + } + val unknownMessage = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = createMessageContent(UnknownMessageType)) + val result = UnknownMessageType to formatter.processMessageItem(unknownMessage, isDmRoom = isDm) + if (isDm) { + resultsInDm.add(result) + } else { + resultsInRoom.add(result) + } + } + + // Verify results of DM mode + for ((type, result) in resultsInDm) { + val expectedResult = when (type) { + is VideoMessageType -> "Video." + is AudioMessageType -> "Audio" + is ImageMessageType -> "Image." + is FileMessageType -> "File" + is EmoteMessageType -> "- $senderName ${type.body}" + is TextMessageType, is NoticeMessageType -> body + UnknownMessageType -> "Event type not handled by EAX" + } + Truth.assertWithMessage("$type was not properly handled").that(result).isEqualTo(expectedResult) + } + + // Verify results of Room mode + for ((type, result) in resultsInRoom) { + val string = result.toString() + val expectedResult = when (type) { + is VideoMessageType -> "$senderName: Video." + is AudioMessageType -> "$senderName: Audio" + is ImageMessageType -> "$senderName: Image." + is FileMessageType -> "$senderName: File" + is EmoteMessageType -> "- $senderName ${type.body}" + is TextMessageType, is NoticeMessageType -> "$senderName: $body" + UnknownMessageType -> "$senderName: Event type not handled by EAX" + } + val shouldCreateAnnotatedString = when (type) { + is VideoMessageType -> true + is AudioMessageType -> true + is ImageMessageType -> true + is FileMessageType -> true + is EmoteMessageType -> false + is TextMessageType, is NoticeMessageType -> true + UnknownMessageType -> true + } + if (shouldCreateAnnotatedString) { + Truth.assertWithMessage("$type doesn't produce an AnnotatedString") + .that(result) + .isInstanceOf(AnnotatedString::class.java) + } + Truth.assertWithMessage("$type was not properly handled").that(string).isEqualTo(expectedResult) + } + } + + // endregion + + // region Membership change + + @Test + @Config(qualifiers = "en") + fun `Membership change - joined`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.JOINED) + + val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youJoinedRoom = formatter.processMessageItem(youJoinedRoomEvent, false) + Truth.assertThat(youJoinedRoom).isEqualTo("You joined the room") + + val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneJoinedRoom = formatter.processMessageItem(someoneJoinedRoomEvent, false) + Truth.assertThat(someoneJoinedRoom).isEqualTo("${someoneContent.userId.value} joined the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - left`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.LEFT) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.LEFT) + + val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youLeftRoom = formatter.processMessageItem(youLeftRoomEvent, false) + Truth.assertThat(youLeftRoom).isEqualTo("You left the room") + + val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneLeftRoom = formatter.processMessageItem(someoneLeftRoomEvent, false) + Truth.assertThat(someoneLeftRoom).isEqualTo("${someoneContent.userId.value} left the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - banned`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.BANNED) + val youKickedContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KICKED_AND_BANNED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.BANNED) + val someoneKickedContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KICKED_AND_BANNED) + + val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youBanned = formatter.processMessageItem(youBannedEvent, false) + Truth.assertThat(youBanned).isEqualTo("You banned ${youContent.userId.value}") + + val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) + val youKickedBanned = formatter.processMessageItem(youKickBannedEvent, false) + Truth.assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId.value}") + + val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneBanned = formatter.processMessageItem(someoneBannedEvent, false) + Truth.assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId.value}") + + val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) + val someoneKickBanned = formatter.processMessageItem(someoneKickBannedEvent, false) + Truth.assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId.value}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - unban`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.UNBANNED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.UNBANNED) + + val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youUnbanned = formatter.processMessageItem(youUnbannedEvent, false) + Truth.assertThat(youUnbanned).isEqualTo("You unbanned ${youContent.userId.value}") + + val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneUnbanned = formatter.processMessageItem(someoneUnbannedEvent, false) + Truth.assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId.value}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - kicked`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KICKED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KICKED) + + val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKicked = formatter.processMessageItem(youKickedEvent, false) + Truth.assertThat(youKicked).isEqualTo("You removed ${youContent.userId.value}") + + val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKicked = formatter.processMessageItem(someoneKickedEvent, false) + Truth.assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId.value}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invited`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.INVITED) + + val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val youWereInvited = formatter.processMessageItem(youWereInvitedEvent, false) + Truth.assertThat(youWereInvited).isEqualTo("$otherName invited you") + + val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youInvited = formatter.processMessageItem(youInvitedEvent, false) + Truth.assertThat(youInvited).isEqualTo("You invited ${someoneContent.userId.value}") + + val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneInvited = formatter.processMessageItem(someoneInvitedEvent, false) + Truth.assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId.value}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation accepted`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.INVITATION_ACCEPTED) + + val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youAcceptedInvite = formatter.processMessageItem(youAcceptedInviteEvent, false) + Truth.assertThat(youAcceptedInvite).isEqualTo("You accepted the invite") + + val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedInvite = formatter.processMessageItem(someoneAcceptedInviteEvent, false) + Truth.assertThat(someoneAcceptedInvite).isEqualTo("${someoneContent.userId.value} accepted the invite") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation rejected`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_REJECTED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.INVITATION_REJECTED) + + val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRejectedInvite = formatter.processMessageItem(youRejectedInviteEvent, false) + Truth.assertThat(youRejectedInvite).isEqualTo("You rejected the invitation") + + val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRejectedInvite = formatter.processMessageItem(someoneRejectedInviteEvent, false) + Truth.assertThat(someoneRejectedInvite).isEqualTo("${someoneContent.userId.value} rejected the invitation") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation revoked`() { + val otherName = "Someone" + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.INVITATION_REVOKED) + + val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youRevokedInvite = formatter.processMessageItem(youRevokedInviteEvent, false) + Truth.assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for ${someoneContent.userId.value} to join the room") + + val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRevokedInvite = formatter.processMessageItem(someoneRevokedInviteEvent, false) + Truth.assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for ${someoneContent.userId.value} to join the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knocked`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCKED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KNOCKED) + + val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKnocked = formatter.processMessageItem(youKnockedEvent, false) + Truth.assertThat(youKnocked).isEqualTo("You requested to join") + + val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKnocked = formatter.processMessageItem(someoneKnockedEvent, false) + Truth.assertThat(someoneKnocked).isEqualTo("${someoneContent.userId.value} requested to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock accepted`() { + val otherName = "Someone" + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KNOCK_ACCEPTED) + + val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youAcceptedKnock = formatter.processMessageItem(youAcceptedKnockEvent, false) + Truth.assertThat(youAcceptedKnock).isEqualTo("${someoneContent.userId.value} allowed you to join") + + val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedKnock = formatter.processMessageItem(someoneAcceptedKnockEvent, false) + Truth.assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed ${someoneContent.userId.value} to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock retracted`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_RETRACTED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KNOCK_RETRACTED) + + val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRetractedKnock = formatter.processMessageItem(youRetractedKnockEvent, false) + Truth.assertThat(youRetractedKnock).isEqualTo("You cancelled your request to join") + + val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRetractedKnock = formatter.processMessageItem(someoneRetractedKnockEvent, false) + Truth.assertThat(someoneRetractedKnock).isEqualTo("${someoneContent.userId.value} is no longer interested in joining") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock denied`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_DENIED) + val someoneContent = RoomMembershipContent(UserId("someone_else"), MembershipChange.KNOCK_DENIED) + + val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youDeniedKnock = formatter.processMessageItem(youDeniedKnockEvent, false) + Truth.assertThat(youDeniedKnock).isEqualTo("You rejected ${someoneContent.userId.value}'s request to join") + + val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneDeniedKnock = formatter.processMessageItem(someoneDeniedKnockEvent, false) + Truth.assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected ${someoneContent.userId.value}'s request to join") + + val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val someoneDeniedYourKnock = formatter.processMessageItem(someoneDeniedYourKnockEvent, false) + Truth.assertThat(someoneDeniedYourKnock).isEqualTo("$otherName rejected your request to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - others`() { + val otherChanges = arrayOf(MembershipChange.NONE, MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED) + + val results = otherChanges.map { change -> + val content = RoomMembershipContent(A_USER_ID, change) + val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.processMessageItem(event, false) + change to result + } + val expected = otherChanges.map { it to null } + Truth.assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Room State + + @Test + @Config(qualifiers = "en") + fun `Room state change - avatar`() { + val otherName = "Someone" + val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar")) + val removedContent = StateContent("", OtherState.RoomAvatar(null)) + + val youChangedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomAvatar = formatter.processMessageItem(youChangedRoomAvatarEvent, false) + Truth.assertThat(youChangedRoomAvatar).isEqualTo("You changed the room avatar") + + val someoneChangedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomAvatar = formatter.processMessageItem(someoneChangedRoomAvatarEvent, false) + Truth.assertThat(someoneChangedRoomAvatar).isEqualTo("$otherName changed the room avatar") + + val youRemovedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomAvatar = formatter.processMessageItem(youRemovedRoomAvatarEvent, false) + Truth.assertThat(youRemovedRoomAvatar).isEqualTo("You removed the room avatar") + + val someoneRemovedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomAvatar = formatter.processMessageItem(someoneRemovedRoomAvatarEvent, false) + Truth.assertThat(someoneRemovedRoomAvatar).isEqualTo("$otherName removed the room avatar") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - create`() { + val otherName = "Someone" + val content = StateContent("", OtherState.RoomCreate) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.processMessageItem(youCreatedRoomMessage, false) + Truth.assertThat(youCreatedRoom).isEqualTo("You created the room") + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.processMessageItem(someoneCreatedRoomEvent, false) + Truth.assertThat(someoneCreatedRoom).isEqualTo("$otherName created the room") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - encryption`() { + val otherName = "Someone" + val content = StateContent("", OtherState.RoomEncryption) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.processMessageItem(youCreatedRoomMessage, false) + Truth.assertThat(youCreatedRoom).isEqualTo("Encryption enabled") + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.processMessageItem(someoneCreatedRoomEvent, false) + Truth.assertThat(someoneCreatedRoom).isEqualTo("Encryption enabled") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room name`() { + val otherName = "Someone" + val newName = "New name" + val changedContent = StateContent("", OtherState.RoomName(newName)) + val removedContent = StateContent("", OtherState.RoomName(null)) + + val youChangedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomName = formatter.processMessageItem(youChangedRoomNameEvent, false) + Truth.assertThat(youChangedRoomName).isEqualTo("You changed the room name to: $newName") + + val someoneChangedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomName = formatter.processMessageItem(someoneChangedRoomNameEvent, false) + Truth.assertThat(someoneChangedRoomName).isEqualTo("$otherName changed the room name to: $newName") + + val youRemovedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomName = formatter.processMessageItem(youRemovedRoomNameEvent, false) + Truth.assertThat(youRemovedRoomName).isEqualTo("You removed the room name") + + val someoneRemovedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomName = formatter.processMessageItem(someoneRemovedRoomNameEvent, false) + Truth.assertThat(someoneRemovedRoomName).isEqualTo("$otherName removed the room name") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - third party invite`() { + val otherName = "Someone" + val inviteeName = "Alice" + val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName)) + val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) + + val youInvitedSomeoneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youInvitedSomeone = formatter.processMessageItem(youInvitedSomeoneEvent, false) + Truth.assertThat(youInvitedSomeone).isEqualTo("You sent an invitation to $inviteeName to join the room") + + val someoneInvitedSomeoneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneInvitedSomeone = formatter.processMessageItem(someoneInvitedSomeoneEvent, false) + Truth.assertThat(someoneInvitedSomeone).isEqualTo("$otherName sent an invitation to $inviteeName to join the room") + + val youInvitedNoOneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youInvitedNoOne = formatter.processMessageItem(youInvitedNoOneEvent, false) + Truth.assertThat(youInvitedNoOne).isNull() + + val someoneInvitedNoOneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneInvitedNoOne = formatter.processMessageItem(someoneInvitedNoOneEvent, false) + Truth.assertThat(someoneInvitedNoOne).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room topic`() { + val otherName = "Someone" + val roomTopic = "New topic" + val changedContent = StateContent("", OtherState.RoomTopic(roomTopic)) + val removedContent = StateContent("", OtherState.RoomTopic(null)) + + val youChangedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomTopic = formatter.processMessageItem(youChangedRoomTopicEvent, false) + Truth.assertThat(youChangedRoomTopic).isEqualTo("You changed the topic to: $roomTopic") + + val someoneChangedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomTopic = formatter.processMessageItem(someoneChangedRoomTopicEvent, false) + Truth.assertThat(someoneChangedRoomTopic).isEqualTo("$otherName changed the topic to: $roomTopic") + + val youRemovedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomTopic = formatter.processMessageItem(youRemovedRoomTopicEvent, false) + Truth.assertThat(youRemovedRoomTopic).isEqualTo("You removed the room topic") + + val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomTopic = formatter.processMessageItem(someoneRemovedRoomTopicEvent, false) + Truth.assertThat(someoneRemovedRoomTopic).isEqualTo("$otherName removed the room topic") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - others must return null`() { + val otherStates = arrayOf( + OtherState.PolicyRuleRoom, OtherState.PolicyRuleServer, OtherState.PolicyRuleUser, OtherState.RoomAliases, OtherState.RoomCanonicalAlias, + OtherState.RoomGuestAccess, OtherState.RoomHistoryVisibility, OtherState.RoomJoinRules, OtherState.RoomPinnedEvents, OtherState.RoomPowerLevels, + OtherState.RoomServerAcl, OtherState.RoomTombstone, OtherState.SpaceChild, OtherState.SpaceParent, OtherState.Custom("custom_event_type") + ) + + val results = otherStates.map { state -> + val content = StateContent("", state) + val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.processMessageItem(event, false) + state to result + } + val expected = otherStates.map { it to null } + Truth.assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Profile change + + @Test + @Config(qualifiers = "en") + fun `Profile change - avatar`() { + val otherName = "Someone" + val changedContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = "old_avatar_url") + val setContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = null) + val removedContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = "old_avatar_url") + val invalidContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = null) + val sameContent = aProfileChangeMessageContent(avatarUrl = "same_avatar_url", prevAvatarUrl = "same_avatar_url") + + val youChangedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedAvatar = formatter.processMessageItem(youChangedAvatarEvent, false) + Truth.assertThat(youChangedAvatar).isEqualTo("You changed your avatar") + + val someoneChangeAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangeAvatar = formatter.processMessageItem(someoneChangeAvatarEvent, false) + Truth.assertThat(someoneChangeAvatar).isEqualTo("$otherName changed their avatar") + + val youSetAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetAvatar = formatter.processMessageItem(youSetAvatarEvent, false) + Truth.assertThat(youSetAvatar).isEqualTo("You changed your avatar") + + val someoneSetAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetAvatar = formatter.processMessageItem(someoneSetAvatarEvent, false) + Truth.assertThat(someoneSetAvatar).isEqualTo("$otherName changed their avatar") + + val youRemovedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedAvatar = formatter.processMessageItem(youRemovedAvatarEvent, false) + Truth.assertThat(youRemovedAvatar).isEqualTo("You changed your avatar") + + val someoneRemovedAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedAvatar = formatter.processMessageItem(someoneRemovedAvatarEvent, false) + Truth.assertThat(someoneRemovedAvatar).isEqualTo("$otherName changed their avatar") + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.processMessageItem(unchangedEvent, false) + Truth.assertThat(unchangedResult).isNull() + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.processMessageItem(invalidEvent, false) + Truth.assertThat(invalidResult).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val otherName = "Someone" + val changedContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = oldDisplayName) + val setContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = null) + val removedContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = oldDisplayName) + val sameContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = newDisplayName) + val invalidContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = null) + + val youChangedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedDisplayName = formatter.processMessageItem(youChangedDisplayNameEvent, false) + Truth.assertThat(youChangedDisplayName).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName") + + val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedDisplayName = formatter.processMessageItem(someoneChangedDisplayNameEvent, false) + Truth.assertThat(someoneChangedDisplayName).isEqualTo("$otherName changed their display name from $oldDisplayName to $newDisplayName") + + val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetDisplayName = formatter.processMessageItem(youSetDisplayNameEvent, false) + Truth.assertThat(youSetDisplayName).isEqualTo("You set your display name to $newDisplayName") + + val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetDisplayName = formatter.processMessageItem(someoneSetDisplayNameEvent, false) + Truth.assertThat(someoneSetDisplayName).isEqualTo("$otherName set their display name to $newDisplayName") + + val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedDisplayName = formatter.processMessageItem(youRemovedDisplayNameEvent, false) + Truth.assertThat(youRemovedDisplayName).isEqualTo("You removed your display name (it was $oldDisplayName)") + + val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedDisplayName = formatter.processMessageItem(someoneRemovedDisplayNameEvent, false) + Truth.assertThat(someoneRemovedDisplayName).isEqualTo("$otherName removed their display name (it was $oldDisplayName)") + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.processMessageItem(unchangedEvent, false) + Truth.assertThat(unchangedResult).isNull() + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.processMessageItem(invalidEvent, false) + Truth.assertThat(invalidResult).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name & avatar`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val changedContent = aProfileChangeMessageContent( + displayName = newDisplayName, + prevDisplayName = oldDisplayName, + avatarUrl = "new_avatar_url", + prevAvatarUrl = "old_avatar_url", + ) + val invalidContent = aProfileChangeMessageContent( + displayName = null, + prevDisplayName = null, + avatarUrl = null, + prevAvatarUrl = null, + ) + val sameContent = aProfileChangeMessageContent( + displayName = newDisplayName, + prevDisplayName = newDisplayName, + avatarUrl = "same_avatar_url", + prevAvatarUrl = "same_avatar_url", + ) + + val youChangedBothEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedBoth = formatter.processMessageItem(youChangedBothEvent, false) + Truth.assertThat(youChangedBoth).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName\n(avatar was changed too)") + + val invalidContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = invalidContent) + val invalidMessage = formatter.processMessageItem(invalidContentEvent, false) + Truth.assertThat(invalidMessage).isNull() + + val sameContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = sameContent) + val sameMessage = formatter.processMessageItem(sameContentEvent, false) + Truth.assertThat(sameMessage).isNull() + } + + // endregion + + private fun createRoomEvent(sentByYou: Boolean, senderDisplayName: String?, content: EventContent): EventTimelineItem { + val sender = if (sentByYou) A_USER_ID else UserId("someone_else") + val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null) + return anEventTimelineItem(content = content, senderProfile = profile, sender = sender) + } +} diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt new file mode 100644 index 0000000000..e0763748bf --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt @@ -0,0 +1,31 @@ +/* + * 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.roomlist.impl + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +class FakeRoomLastMessageFormatter : RoomLastMessageFormatter { + + private var processMessageItemResult: CharSequence? = null + override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + return processMessageItemResult + } + + fun givenRoomSummaryResult(result: CharSequence?) { + processMessageItemResult = result + } +} diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index aae0dc57de..1e8c15bf6d 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -21,14 +21,13 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth import io.element.android.features.roomlist.impl.model.RoomListRoomSummary -import io.element.android.libraries.dateformatter.api.LastMessageFormatter -import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter +import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION -import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -48,6 +47,7 @@ class RoomListPresenterTests { val presenter = RoomListPresenter( FakeMatrixClient(A_SESSION_ID), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -73,6 +73,7 @@ class RoomListPresenterTests { userAvatarURLString = Result.failure(AN_EXCEPTION), ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -92,6 +93,7 @@ class RoomListPresenterTests { val presenter = RoomListPresenter( FakeMatrixClient(A_SESSION_ID), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -115,6 +117,7 @@ class RoomListPresenterTests { roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -143,6 +146,7 @@ class RoomListPresenterTests { roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -176,6 +180,7 @@ class RoomListPresenterTests { roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -220,6 +225,7 @@ class RoomListPresenterTests { roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService().apply { givenIsReady(true) givenVerifiedStatus(SessionVerifiedStatus.NotVerified) @@ -245,6 +251,7 @@ class RoomListPresenterTests { roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), + FakeRoomLastMessageFormatter(), FakeSessionVerificationService().apply { givenIsReady(true) givenVerificationFlowState(VerificationFlowState.Finished) @@ -261,8 +268,8 @@ class RoomListPresenterTests { } } - private fun createDateFormatter(): LastMessageFormatter { - return FakeLastMessageFormatter().apply { + private fun createDateFormatter(): LastMessageTimestampFormatter { + return FakeLastMessageTimestampFormatter().apply { givenFormat(A_FORMATTED_DATE) } } @@ -276,7 +283,7 @@ private val aRoomListRoomSummary = RoomListRoomSummary( name = A_ROOM_NAME, hasUnread = true, timestamp = A_FORMATTED_DATE, - lastMessage = A_MESSAGE, + lastMessage = "", avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME), isPlaceholder = false, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68b8dbc8af..a435ede569 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -111,6 +111,7 @@ test_orchestrator = "androidx.test:orchestrator:1.4.2" test_turbine = "app.cash.turbine:turbine:0.12.1" test_truth = "com.google.truth:truth:1.1.3" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.10" +test_robolectric = "org.robolectric:robolectric:4.9" # Others coil = { module = "io.coil-kt:coil", version.ref = "coil" } diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt similarity index 94% rename from libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageFormatter.kt rename to libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt index 7f61dfac5d..00a0e6b2bd 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt @@ -16,6 +16,6 @@ package io.element.android.libraries.dateformatter.api -interface LastMessageFormatter { +interface LastMessageTimestampFormatter { fun format(timestamp: Long?): String } diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt similarity index 93% rename from libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt rename to libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt index fd7afe55e7..78294870d2 100644 --- a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt @@ -17,15 +17,15 @@ package io.element.android.libraries.dateformatter.impl import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.dateformatter.api.LastMessageFormatter +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) -class DefaultLastMessageFormatter @Inject constructor( +class DefaultLastMessageTimestampFormatter @Inject constructor( private val localDateTimeProvider: LocalDateTimeProvider, private val dateFormatters: DateFormatters, -) : LastMessageFormatter { +) : LastMessageTimestampFormatter { override fun format(timestamp: Long?): String { if (timestamp == null) return "" diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt similarity index 94% rename from libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt rename to libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt index 1d8390ec9f..483e27af71 100644 --- a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt @@ -17,14 +17,14 @@ package io.element.android.libraries.dateformatter.impl import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.dateformatter.api.LastMessageFormatter +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeClock import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import org.junit.Test import java.util.Locale -class DefaultLastMessageFormatterTest { +class DefaultLastMessageTimestampFormatterTest { @Test fun `test null`() { @@ -100,10 +100,10 @@ class DefaultLastMessageFormatterTest { /** * Create DefaultLastMessageFormatter and set current time to the provided date. */ - private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter { + private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageTimestampFormatter { val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) } val localDateTimeProvider = LocalDateTimeProvider(clock, TimeZone.UTC) val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC) - return DefaultLastMessageFormatter(localDateTimeProvider, dateFormatters) + return DefaultLastMessageTimestampFormatter(localDateTimeProvider, dateFormatters) } } diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt similarity index 90% rename from libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageFormatter.kt rename to libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt index 539da5e6ac..47226c34d9 100644 --- a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageFormatter.kt +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt @@ -16,9 +16,9 @@ package io.element.android.libraries.dateformatter.test -import io.element.android.libraries.dateformatter.api.LastMessageFormatter +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter -class FakeLastMessageFormatter : LastMessageFormatter { +class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter { private var format = "" fun givenFormat(format: String) { this.format = format diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt index 1a5d55861a..3ebb7eabfa 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.message.RoomMessage sealed interface RoomSummary { data class Empty(val identifier: String) : RoomSummary @@ -35,7 +36,7 @@ data class RoomSummaryDetails( val name: String, val isDirect: Boolean, val avatarURLString: String?, - val lastMessage: CharSequence?, + val lastMessage: RoomMessage?, val lastMessageTimestamp: Long?, val unreadNotificationCount: Int, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt index e6f5ba3a5c..b778cad6a3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt @@ -18,10 +18,11 @@ package io.element.android.libraries.matrix.api.room.message import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem data class RoomMessage( val eventId: EventId, - val body: String, + val event: EventTimelineItem, val sender: UserId, val originServerTs: Long, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt index e40729d676..5211db22b4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.impl.room +import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomSummaryDetails import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory @@ -28,19 +29,16 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto val latestRoomMessage = slidingSyncRoom.latestRoomMessage()?.use { roomMessageFactory.create(it) } - val computedLastMessage = when { - latestRoomMessage == null -> null - slidingSyncRoom.isDm() == true -> latestRoomMessage.body - else -> "${latestRoomMessage.sender.value}: ${latestRoomMessage.body}" - } return RoomSummaryDetails( roomId = RoomId(slidingSyncRoom.roomId()), name = slidingSyncRoom.name() ?: slidingSyncRoom.roomId(), isDirect = slidingSyncRoom.isDm() ?: false, avatarURLString = room?.avatarUrl(), unreadNotificationCount = slidingSyncRoom.unreadNotifications().use { it.notificationCount().toInt() }, - lastMessage = computedLastMessage, + lastMessage = latestRoomMessage, lastMessageTimestamp = latestRoomMessage?.originServerTs ) } + + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt index 84fa57659e..3c298b5ec6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt @@ -16,19 +16,19 @@ package io.element.android.libraries.matrix.impl.room.message -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.message.RoomMessage -import org.matrix.rustcomponents.sdk.EventTimelineItem +import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper +import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem class RoomMessageFactory { - fun create(eventTimelineItem: EventTimelineItem?): RoomMessage? { + fun create(eventTimelineItem: RustEventTimelineItem?): RoomMessage? { eventTimelineItem ?: return null + val mappedTimelineItem = EventTimelineItemMapper().map(eventTimelineItem) return RoomMessage( - eventId = EventId(eventTimelineItem.eventId() ?: ""), - body = eventTimelineItem.content().asMessage()?.body() ?: "", - sender = UserId(eventTimelineItem.sender()), - originServerTs = eventTimelineItem.timestamp().toLong() + eventId = mappedTimelineItem.eventId!!, + event = mappedTimelineItem, + sender = mappedTimelineItem.sender, + originServerTs = mappedTimelineItem.timestamp, ) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 78956ea0f7..a09c8bc8f9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -33,6 +33,7 @@ val A_SPACE_ID = SpaceId("!aSpaceId") val A_ROOM_ID = RoomId("!aRoomId") val A_THREAD_ID = ThreadId("\$aThreadId") val AN_EVENT_ID = EventId("\$anEventId") +const val A_UNIQUE_ID = "aUniqueId" const val A_ROOM_NAME = "A room name" const val A_MESSAGE = "Hello world!" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 86e56a0bf3..960d2c3974 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -16,19 +16,33 @@ package io.element.android.libraries.matrix.test.room +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME fun aRoomSummaryFilled( roomId: RoomId = A_ROOM_ID, name: String = A_ROOM_NAME, isDirect: Boolean = false, avatarURLString: String? = null, - lastMessage: CharSequence? = A_MESSAGE, + lastMessage: RoomMessage? = aRoomMessage(), lastMessageTimestamp: Long? = null, unreadNotificationCount: Int = 2, ) = RoomSummary.Filled( @@ -48,7 +62,7 @@ fun aRoomSummaryDetail( name: String = A_ROOM_NAME, isDirect: Boolean = false, avatarURLString: String? = null, - lastMessage: CharSequence? = A_MESSAGE, + lastMessage: RoomMessage? = aRoomMessage(), lastMessageTimestamp: Long? = null, unreadNotificationCount: Int = 2, ) = RoomSummaryDetails( @@ -60,3 +74,65 @@ fun aRoomSummaryDetail( lastMessageTimestamp = lastMessageTimestamp, unreadNotificationCount = unreadNotificationCount, ) + +fun aRoomMessage( + eventId: EventId = AN_EVENT_ID, + event: EventTimelineItem = anEventTimelineItem(), + userId: UserId = A_USER_ID, + timestamp: Long = 0L, +) = RoomMessage( + eventId = eventId, + event = event, + sender = userId, + originServerTs = timestamp, +) + +fun anEventTimelineItem( + uniqueIdentifier: String = A_UNIQUE_ID, + eventId: EventId = AN_EVENT_ID, + isEditable: Boolean = false, + isLocal: Boolean = false, + isOwn: Boolean = false, + isRemote: Boolean = false, + localSendState: EventSendState? = null, + reactions: List = emptyList(), + sender: UserId = A_USER_ID, + senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(), + timestamp: Long = 0L, + content: EventContent = aProfileChangeMessageContent(), +) = EventTimelineItem( + uniqueIdentifier = uniqueIdentifier, + eventId = eventId, + isEditable = isEditable, + isLocal = isLocal, + isOwn = isOwn, + isRemote = isRemote, + localSendState = localSendState, + reactions = reactions, + sender = sender, + senderProfile = senderProfile, + timestamp = timestamp, + content = content, +) + +fun aProfileTimelineDetails( + displayName: String? = A_USER_NAME, + displayNameAmbiguous: Boolean = false, + avatarUrl: String? = null +): ProfileTimelineDetails = ProfileTimelineDetails.Ready( + displayName = displayName, + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = avatarUrl, +) + +fun aProfileChangeMessageContent( + displayName: String? = null, + prevDisplayName: String? = null, + avatarUrl: String? = null, + prevAvatarUrl: String? = null, +) = ProfileChangeContent( + displayName = displayName, + prevDisplayName = prevDisplayName, + avatarUrl = avatarUrl, + prevAvatarUrl = prevAvatarUrl, +) diff --git a/libraries/ui-strings/src/main/res/values/strings_eax.xml b/libraries/ui-strings/src/main/res/values/strings_eax.xml index 4bb24b2dc2..5f0c0baf7c 100644 --- a/libraries/ui-strings/src/main/res/values/strings_eax.xml +++ b/libraries/ui-strings/src/main/res/values/strings_eax.xml @@ -18,6 +18,21 @@ Search for someone New room + + You accepted the invite + %1$s accepted the invite + %1$s requested to join + You requested to join + %1$s allowed %2$s to join + %1$s allowed you to join + %1$s is no longer interested in joining + You cancelled your request to join + %1$s rejected %2$s\'s request to join + You rejected %1$s\'s request to join + %1$s rejected your request to join + %1$s made an unknown change to their membership + Unsupported event + Event type not handled by EAX Open an existing session Waiting to accept request diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index cbd0e9839e..1accae8464 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -72,7 +73,7 @@ class MainActivity : ComponentActivity() { val sessionId = matrixAuthenticationService.getLatestSessionId()!! matrixAuthenticationService.restoreSession(sessionId).getOrNull() } - RoomListScreen(matrixClient = matrixClient!!).Content(modifier) + RoomListScreen(LocalContext.current, matrixClient!!).Content(modifier) } } } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 714692d48f..6c95dee210 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -16,13 +16,15 @@ package io.element.android.samples.minimal +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier +import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter import io.element.android.features.roomlist.impl.RoomListPresenter import io.element.android.features.roomlist.impl.RoomListView import io.element.android.libraries.dateformatter.impl.DateFormatters -import io.element.android.libraries.dateformatter.impl.DefaultLastMessageFormatter +import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -32,16 +34,21 @@ import kotlinx.datetime.TimeZone import java.util.Locale class RoomListScreen( - private val matrixClient: MatrixClient + context: Context, + private val matrixClient: MatrixClient, ) { - private val clock = Clock.System private val locale = Locale.getDefault() private val timeZone = TimeZone.currentSystemDefault() private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() - private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters), sessionVerificationService) + private val presenter = RoomListPresenter( + matrixClient, + DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), + DefaultRoomLastMessageFormatter(context, matrixClient), + sessionVerificationService + ) @Composable fun Content(modifier: Modifier = Modifier) {