diff --git a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/i18n/I18n.kt b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/i18n/I18n.kt index 96952ab0b32ce644cf949c3b7aff42de86158346..955a82a43ccb163ce45ad46550326168c913b110 100644 --- a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/i18n/I18n.kt +++ b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/i18n/I18n.kt @@ -152,6 +152,16 @@ abstract class I18n(languages: Languages, settings: MatrixMessengerSettingsHolde DE - "Nachricht wurde von $username gelöscht" } + fun eventMessageRedactedByMe() = translate { + EN - "You deleted this message" + DE - "Sie haben diese Nachricht gelöscht" + } + + fun eventMessageRedactedByUnknown() = translate { + EN - "This message has been deleted" + DE - "Diese Nachricht ist gelöscht worden" + } + fun eventRoomCreated(username: String, groupOrChat: String) = translate { EN - "$username has created $groupOrChat" DE - "$username hat $groupOrChat erstellt" diff --git a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModel.kt b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModel.kt index 8a9644f0c7ebd2dd757c8aa0129a774223c465e0..b72859b149c2197d5c303be369d9857b337b8efc 100644 --- a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModel.kt +++ b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModel.kt @@ -6,10 +6,13 @@ import de.connect2x.trixnity.messenger.viewmodel.i18n import de.connect2x.trixnity.messenger.viewmodel.util.formatDate import de.connect2x.trixnity.messenger.viewmodel.util.formatTime import de.connect2x.trixnity.messenger.viewmodel.util.timezone +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.Instant @@ -17,9 +20,13 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import net.folivo.trixnity.client.store.TimelineEvent import net.folivo.trixnity.client.store.unsigned +import net.folivo.trixnity.client.user +import net.folivo.trixnity.core.model.RoomId +import net.folivo.trixnity.core.model.UserId import net.folivo.trixnity.core.model.events.RedactedEventContent import net.folivo.trixnity.core.model.events.originTimestampOrNull +private val log = KotlinLogging.logger { } interface RedactedMessageViewModelFactory { fun create( @@ -35,6 +42,8 @@ interface RedactedMessageViewModelFactory { showSender: Flow<Boolean>, sender: Flow<UserInfoElement>, invitation: Flow<String?>, + selectedRoomId: RoomId, + redactedBy: UserId? ): RedactedMessageViewModel { return RedactedMessageViewModelImpl( viewModelContext, @@ -46,9 +55,11 @@ interface RedactedMessageViewModelFactory { isByMe, showChatBubbleEdge, showBigGap, + selectedRoomId, showSender, sender, invitation, + redactedBy ) } @@ -70,9 +81,11 @@ open class RedactedMessageViewModelImpl( override val isByMe: Boolean, override val showChatBubbleEdge: Boolean, override val showBigGap: Boolean, + selectedRoomId: RoomId, showSender: Flow<Boolean>, sender: Flow<UserInfoElement>, invitation: Flow<String?>, + redactedBy: UserId?, ) : RedactedMessageViewModel, MatrixClientViewModelContext by viewModelContext { override val invitation: StateFlow<String?> = invitation.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null) @@ -81,18 +94,24 @@ open class RedactedMessageViewModelImpl( override val showSender: StateFlow<Boolean> = showSender.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), true) - override val formattedMessage = sender.map { userInfo -> - i18n.eventMessageRedacted(userInfo.name) - }.stateIn( - coroutineScope, - SharingStarted.WhileSubscribed(), - i18n.eventMessageRedacted(i18n.commonUnknown()) - ) + override val formattedMessage = when (redactedBy) { + null -> MutableStateFlow(i18n.eventMessageRedactedByUnknown()) + matrixClient.userId -> MutableStateFlow(i18n.eventMessageRedactedByMe()) + else -> matrixClient.user.getById(selectedRoomId, redactedBy).map { + i18n.eventMessageRedacted(it?.name ?: redactedBy.full) + }.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(), + i18n.eventMessageRedacted(i18n.commonUnknown()) + ) + } - override val redactedAtDateTime: String? = timelineEvent?.unsigned?.redactedBecause?.originTimestampOrNull?.let { - val localDateTime = Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.of(timezone())) - "${formatDate(localDateTime)}, ${formatTime(localDateTime)}" - } + + override val redactedAtDateTime: String? = + timelineEvent?.unsigned?.redactedBecause?.originTimestampOrNull?.let { + val localDateTime = Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.of(timezone())) + "${formatDate(localDateTime)}, ${formatTime(localDateTime)}" + } } class PreviewRedactedMessageViewModel() : RedactedMessageViewModel { @@ -107,4 +126,4 @@ class PreviewRedactedMessageViewModel() : RedactedMessageViewModel { override val formattedDate: String = "23.12.21" override val showDateAbove: Boolean = false override val redactedAtDateTime: String = "25.12.21, 13:18" -} \ No newline at end of file +} diff --git a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/TimelineElementHolderViewModel.kt b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/TimelineElementHolderViewModel.kt index 03338a8ae86bfad2fee5316760a65b20ba693bcf..263ac352806b76ed113f80a64637bdf2b526749f 100644 --- a/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/TimelineElementHolderViewModel.kt +++ b/trixnity-messenger/src/commonMain/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/TimelineElementHolderViewModel.kt @@ -47,6 +47,7 @@ import net.folivo.trixnity.client.store.isReplaced import net.folivo.trixnity.client.store.isReplacing import net.folivo.trixnity.client.store.membership import net.folivo.trixnity.client.store.roomId +import net.folivo.trixnity.client.store.unsigned import net.folivo.trixnity.client.user import net.folivo.trixnity.client.user.canSendEvent import net.folivo.trixnity.core.model.EventId @@ -503,6 +504,8 @@ open class TimelineElementHolderViewModelImpl( is RedactedEventContent -> { log.trace { "Create redacted text message view model: ${event.id}" } + val redactedBy = timelineEvent.unsigned?.redactedBecause?.sender + get<RedactedMessageViewModelFactory>().create( viewModelContext = this, timelineEvent = timelineEvent, @@ -513,9 +516,11 @@ open class TimelineElementHolderViewModelImpl( formattedDate = formatDate(receivedDateTime), showDateAbove = showDateAbove, isByMe = isByMe, + selectedRoomId = selectedRoomId, showChatBubbleEdge = showChatBubbleEdge, showBigGap = showChatBubbleEdge, invitation = invitation, + redactedBy = redactedBy ) } diff --git a/trixnity-messenger/src/commonTest/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModelTest.kt b/trixnity-messenger/src/commonTest/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..617e49fd36bf00bf52400609d5e9c417f6357af4 --- /dev/null +++ b/trixnity-messenger/src/commonTest/kotlin/de/connect2x/trixnity/messenger/viewmodel/room/timeline/elements/RedactedMessageViewModelTest.kt @@ -0,0 +1,215 @@ +package de.connect2x.trixnity.messenger.viewmodel.room.timeline.elements + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import de.connect2x.trixnity.messenger.viewmodel.MatrixClientViewModelContextImpl +import de.connect2x.trixnity.messenger.viewmodel.UserInfoElement +import de.connect2x.trixnity.messenger.viewmodel.util.cancelNeverEndingCoroutines +import de.connect2x.trixnity.messenger.viewmodel.util.createTestDefaultTrixnityMessengerModules +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.testCoroutineScheduler +import io.kotest.matchers.shouldBe +import isTimelineEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.setMain +import net.folivo.trixnity.client.MatrixClient +import net.folivo.trixnity.client.room.RoomService +import net.folivo.trixnity.client.store.RoomUser +import net.folivo.trixnity.client.store.TimelineEvent +import net.folivo.trixnity.client.store.eventId +import net.folivo.trixnity.client.user.UserService +import net.folivo.trixnity.core.model.EventId +import net.folivo.trixnity.core.model.RoomId +import net.folivo.trixnity.core.model.UserId +import net.folivo.trixnity.core.model.events.ClientEvent +import net.folivo.trixnity.core.model.events.MessageEventContent +import net.folivo.trixnity.core.model.events.RedactedEventContent +import net.folivo.trixnity.core.model.events.RoomEventContent +import net.folivo.trixnity.core.model.events.m.room.MemberEventContent +import net.folivo.trixnity.core.model.events.m.room.Membership +import org.kodein.mock.Mock +import org.kodein.mock.Mocker +import org.koin.dsl.koinApplication +import org.koin.dsl.module +import kotlin.coroutines.CoroutineContext + +@OptIn(ExperimentalStdlibApi::class) +class RedactedMessageViewModelTest : ShouldSpec() { + + private val roomId = RoomId("room1", "localhost") + private val ourUserId = UserId("bob", "localhost") + private val me = UserId("jonas", "localhost") + val eventId = EventId("0") + + val mocker = Mocker() + + @Mock + lateinit var matrixClientMock: MatrixClient + + @Mock + lateinit var roomServiceMock: RoomService + + @Mock + lateinit var userServiceMock: UserService + + init { + coroutineTestScope = true + + beforeTest { + mocker.reset() + injectMocks(mocker) + with(mocker) { + every { matrixClientMock.di } returns koinApplication { + modules( + module { + single { roomServiceMock } + single { userServiceMock } + } + ) + }.koin + + every { matrixClientMock.userId } returns me + mocker.every { userServiceMock.getById(roomId, ourUserId) } returns MutableStateFlow( + roomUser(me, "TestUser") + ) + } + } + + + should("format generic message when redactedBy is null") { + val timelineEventFlow = timelineEvent(messageEvent(RedactedEventContent("somethig"), sender = ourUserId)) + val cut = redactedMessageViewModel( + timelineEvent = timelineEventFlow, + coroutineContext = coroutineContext, + ) + val subscriberJob = launch { cut.formattedMessage.collect {} } + testCoroutineScheduler.advanceUntilIdle() + + cut.formattedMessage.value shouldBe "This message has been deleted" + + subscriberJob.cancel() + cancelNeverEndingCoroutines() + } + + should("append 'message deleted by me' when redactedBy ID matches current userId") { + val timelineEventFlow = timelineEvent(messageEvent(RedactedEventContent("somethig"), sender = me)) + val cut = redactedMessageViewModel( + timelineEvent = timelineEventFlow, + coroutineContext = coroutineContext, + redactedBy = me + ) + val subscriberJob = launch { cut.formattedMessage.collect {} } + testCoroutineScheduler.advanceUntilIdle() + + cut.formattedMessage.value shouldBe "You deleted this message" + + subscriberJob.cancel() + cancelNeverEndingCoroutines() + } + + + should("append 'redacted by other user' when redactedBy does not match current userId") { + val timelineEventFlow = timelineEvent(messageEvent(RedactedEventContent("somethig"), sender = ourUserId)) + + val cut = redactedMessageViewModel( + timelineEvent = timelineEventFlow, + coroutineContext = coroutineContext, + redactedBy = ourUserId + ) + val subscriberJob = launch { cut.formattedMessage.collect {} } + testCoroutineScheduler.advanceUntilIdle() + + cut.formattedMessage.value shouldBe "message has been deleted by TestUser" + + subscriberJob.cancel() + cancelNeverEndingCoroutines() + } + } + + + @OptIn(ExperimentalStdlibApi::class) + private suspend fun redactedMessageViewModel( + timelineEvent: TimelineEvent, + redactedBy: UserId? = null, + coroutineContext: CoroutineContext, + ): RedactedMessageViewModelImpl { + Dispatchers.setMain(checkNotNull(currentCoroutineContext()[CoroutineDispatcher])) + val di = koinApplication { + modules( + createTestDefaultTrixnityMessengerModules(mapOf(UserId("test", "server") to matrixClientMock)) + ) + }.koin + return RedactedMessageViewModelImpl( + viewModelContext = MatrixClientViewModelContextImpl( + componentContext = DefaultComponentContext(LifecycleRegistry()), + di = di, + userId = UserId("test", "server"), + coroutineContext = coroutineContext + ), + timelineEvent = timelineEvent, + content = timelineEvent.event.content as RedactedEventContent, + formattedDate = "", + showDateAbove = false, + invitation = MutableStateFlow(""), + sender = MutableStateFlow(UserInfoElement("Bob")), + formattedTime = "", + isByMe = false, + redactedBy = redactedBy, + selectedRoomId = roomId, + showBigGap = false, + showSender = MutableStateFlow(false), + showChatBubbleEdge = false + + ) + } + + private fun timelineEvent( + event: ClientEvent.RoomEvent<*>, + content: Result<RoomEventContent>? = null, + previousEvent: TimelineEvent? = null + ): TimelineEvent { + val timelineEvent = TimelineEvent( + event = event, + content = content, + previousEventId = previousEvent?.eventId, + nextEventId = null, + gap = null, + ) + + mocker.every { + roomServiceMock.getPreviousTimelineEvent( + isTimelineEvent(timelineEvent), + isAny(), + ) + } returns + previousEvent?.let { MutableStateFlow(it) } + + return timelineEvent + } + + private fun messageEvent(content: MessageEventContent, sender: UserId) = ClientEvent.RoomEvent.MessageEvent( + content, + id = EventId(""), + sender = sender, + roomId = roomId, + originTimestamp = 0L, + ) + + private fun roomUser(userId: UserId, name: String) = RoomUser( + roomId, + userId, + name, + event = ClientEvent.RoomEvent.StateEvent( + MemberEventContent(membership = Membership.JOIN), + EventId(""), + UserId(""), + RoomId(""), + 0L, + stateKey = "" + ) + ) +}