Merge pull request #443 from vector-im/feature/bma/timelineStateEventFormatting

Timeline state event formatting
This commit is contained in:
Benoit Marty 2023-05-26 10:43:10 +02:00 committed by GitHub
commit a77ff69a65
118 changed files with 2266 additions and 853 deletions

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Personen einladen"</string>
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit kein Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Sie können nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Ihr Homeserver-Administrator muss dies konfigurieren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_password_hint">"Passwort"</string>

View file

@ -41,6 +41,7 @@ dependencies {
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api)

View file

@ -20,9 +20,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemContent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
@ -48,7 +48,7 @@ fun aMessagesState() = MessagesState(
mode = MessageComposerMode.Normal("Hello"),
),
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemContent()),
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
actionListState = anActionListState(),
hasNetworkConnection = true,

View file

@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
@ -139,6 +140,11 @@ fun MessagesView(
}
}
fun onExpandGroupClick(event: TimelineItem.GroupedEvents) {
Timber.v("onExpandGroupClick= ${event.id}")
state.timelineState.eventSink(TimelineEvents.ToggleExpandGroup(event))
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event))
}
@ -189,7 +195,8 @@ fun MessagesView(
.padding(padding)
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
onMessageLongClicked = ::onMessageLongClicked,
onExpandGroupClick = ::onExpandGroupClick,
)
},
snackbarHost = {
@ -214,6 +221,7 @@ fun MessagesViewContent(
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) {
Column(
modifier = modifier
@ -227,7 +235,8 @@ fun MessagesViewContent(
state = state.timelineState,
modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked
onMessageLongClicked = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
)
}
MessageComposerView(

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@ -53,20 +54,25 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
)
}
fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
val actions =
if (timelineItem.content is TimelineItemRedactedContent) {
emptyList()
} else {
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
when (timelineItem.content) {
is TimelineItemRedactedContent,
is TimelineItemStateContent -> {
// TODO Add Share action (also) here, and developer options
emptyList()
}
else -> {
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
}
}
}
}

View file

@ -16,9 +16,11 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
sealed interface TimelineEvents {
object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class ToggleExpandGroup(val event: TimelineItem.GroupedEvents) : TimelineEvents
}

View file

@ -17,15 +17,18 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -42,6 +45,7 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemGrouper: TimelineItemGrouper,
room: MatrixRoom,
) : Presenter<TimelineState> {
@ -53,6 +57,8 @@ class TimelinePresenter @Inject constructor(
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val expandedGroups = remember { mutableStateMapOf<String, Boolean>() }
val timelineItems = timelineItemsFactory
.flow()
.collectAsState()
@ -65,6 +71,9 @@ class TimelinePresenter @Inject constructor(
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.ToggleExpandGroup -> {
expandedGroups[event.event.identifier()] = expandedGroups[event.event.identifier()].orFalse().not()
}
}
}
@ -83,7 +92,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value,
timelineItems = timelineItems.value.toImmutableList(),
timelineItems = timelineItemGrouper.group(timelineItems.value, expandedGroups).toImmutableList(),
eventSink = ::handleEvents
)
}

View file

@ -21,7 +21,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ -55,6 +56,12 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content,
groupPosition = TimelineItemGroupPosition.First
),
// A state event on top of it
aTimelineItemEvent(
isMine = false,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
// 3 items (First Middle Last) with isMine = true
aTimelineItemEvent(
isMine = true,
@ -71,12 +78,18 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content,
groupPosition = TimelineItemGroupPosition.First
),
// A state event on top of it
aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
)
}
internal fun aTimelineItemEvent(
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemContent(),
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
): TimelineItem.Event {
val randomId = "\$" + Random.nextInt().toString()
@ -96,10 +109,3 @@ internal fun aTimelineItemEvent(
groupPosition = groupPosition,
)
}
internal fun aTimelineItemContent(): TimelineItemEventContent {
return TimelineItemTextContent(
body = "Text",
htmlDocument = null
)
}

View file

@ -16,8 +16,8 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
@ -50,19 +50,24 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -82,6 +87,7 @@ fun TimelineView(
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) {
fun onReachedLoadMore() {
@ -93,8 +99,6 @@ fun TimelineView(
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Bottom,
reverseLayout = true
) {
itemsIndexed(
@ -104,9 +108,10 @@ fun TimelineView(
) { index, timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value,
highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked
onLongClick = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@ -125,16 +130,20 @@ fun TimelineView(
@Composable
fun TimelineItemRow(
timelineItem: TimelineItem,
isHighlighted: Boolean,
highlightedItem: String?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> TimelineItemVirtualRow(
virtual = timelineItem
)
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
modifier = modifier,
)
}
is TimelineItem.Event -> {
fun onClick() {
onClick(timelineItem)
}
@ -143,12 +152,54 @@ fun TimelineItemRow(
onLongClick(timelineItem)
}
TimelineItemEventRow(
event = timelineItem,
isHighlighted = isHighlighted,
onClick = ::onClick,
onLongClick = ::onLongClick
)
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
fun onExpandGroupClick() {
onExpandGroupClick(timelineItem)
}
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = timelineItem.expanded,
isHighlighted = !timelineItem.expanded && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick,
)
if (timelineItem.expanded) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
onClick = onClick,
onLongClick = onLongClick,
onExpandGroupClick = {}
)
}
}
}
}
}
}
}
@ -232,6 +283,42 @@ fun TimelineItemEventRow(
}
}
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = contentModifier
)
}
}
}
@Composable
private fun MessageSenderInformation(
sender: String,

View file

@ -84,6 +84,7 @@ fun MessageEventBubble(
fun Modifier.offsetForItem(): Modifier {
return if (state.isMine) {
// FIXME setting y offset to -12.dp can overlap a state event displayed above.
offset(y = -(12.dp))
} else {
offset(x = 20.dp, y = -(12.dp))

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageStateEventContainer(
isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
val shape = RoundedCornerShape(CORNER_RADIUS)
Surface(
modifier = modifier
.widthIn(min = 80.dp)
.clip(shape)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundColor,
shape = shape,
content = content
)
}
@Preview
@Composable
internal fun MessageStateEventContainerLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun MessageStateEventContainerDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
MessageStateEventContainer(
isHighlighted = false,
interactionSource = MutableInteractionSource(),
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
MessageStateEventContainer(
isHighlighted = true,
interactionSource = MutableInteractionSource(),
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
}
}

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
@ -58,5 +59,9 @@ fun TimelineItemEventContentView(
content = content,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier
)
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemStateView(
content: TimelineItemStateContent,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier,
color = MaterialTheme.colorScheme.secondary,
fontSize = 13.sp,
text = content.body,
textAlign = TextAlign.Center,
)
}
@Preview
@Composable
internal fun TimelineItemStateViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemStateViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineItemStateView(
content = aTimelineItemStateEventContent(),
)
}

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.group
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
private val CORNER_RADIUS = 8.dp
@Composable
fun GroupHeaderView(
text: String,
isExpanded: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(
modifier = modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.clip(shape)
.clickable(onClick = onClick),
color = backgroundColor,
shape = shape,
) {
Row(
modifier = Modifier
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
color = MaterialTheme.colorScheme.secondary,
fontSize = 13.sp
)
val icon = if (isExpanded) {
Icons.Default.ExpandLess
} else {
Icons.Default.ExpandMore
}
Icon(icon, "", tint = MaterialTheme.colorScheme.secondary)
}
}
}
}
@Preview
@Composable
fun GroupHeaderViewLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun GroupHeaderViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
GroupHeaderView(
text = "8 room changes (expanded)",
isExpanded = true,
isHighlighted = false,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (not expanded)",
isExpanded = false,
isHighlighted = false,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (expanded/h)",
isExpanded = true,
isHighlighted = true,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (not expanded/h)",
isExpanded = false,
isHighlighted = true,
onClick = {}
)
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
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.MessageContent
@ -26,7 +27,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte
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.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import javax.inject.Inject
@ -43,15 +43,15 @@ class TimelineItemContentFactory @Inject constructor(
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory
) {
fun create(itemContent: EventContent): TimelineItemEventContent {
return when (itemContent) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return when (val itemContent = eventTimelineItem.content) {
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> messageFactory.create(itemContent)
is ProfileChangeContent -> profileChangeFactory.create(itemContent)
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)
is RoomMembershipContent -> roomMembershipFactory.create(itemContent)
is StateContent -> stateFactory.create(itemContent)
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject
class TimelineItemContentProfileChangeFactory @Inject constructor() {
class TimelineItemContentProfileChangeFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: ProfileChangeContent): TimelineItemEventContent {
return TimelineItemUnknownContent
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemProfileChangeContent(text.orEmpty().toString())
}
}

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject
class TimelineItemContentRoomMembershipFactory @Inject constructor() {
class TimelineItemContentRoomMembershipFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: RoomMembershipContent): TimelineItemEventContent {
return TimelineItemUnknownContent
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemRoomMembershipContent(text.orEmpty().toString())
}
}

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject
class TimelineItemContentStateFactory @Inject constructor() {
class TimelineItemContentStateFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: StateContent): TimelineItemEventContent {
return TimelineItemUnknownContent
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemStateEventContent(text.orEmpty().toString())
}
}

View file

@ -67,7 +67,7 @@ class TimelineItemEventFactory @Inject constructor(
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event.content),
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState()

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class TimelineItemGrouper @Inject constructor() {
/**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/
fun group(from: List<TimelineItem>, expandedGroups: Map<String, Boolean>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>()
val currentGroup = mutableListOf<TimelineItem.Event>()
from.forEach { timelineItem ->
if (timelineItem is TimelineItem.Event && timelineItem.canBeGrouped()) {
currentGroup.add(0, timelineItem)
} else {
// timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup, expandedGroups)
currentGroup.clear()
}
result.add(timelineItem)
}
}
if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup, expandedGroups)
}
return result
}
private fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
TimelineItemRedactedContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
}
/**
* Will add a group if there is more than 1 item, else add the item to the list.
*/
private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>,
expandedGroups: Map<String, Boolean>,
) {
if (group.size == 1) {
// Do not create a group with just 1 item, just add the item to the result
add(group.first())
} else {
add(
TimelineItem.GroupedEvents(
expanded = expandedGroups[group.first().id + "_group"].orFalse(),
events = group.toImmutableList()
)
)
}
}

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface TimelineItem {
@ -29,11 +30,13 @@ sealed interface TimelineItem {
fun identifier(): String = when (this) {
is Event -> id
is Virtual -> id
is GroupedEvents -> id
}
fun contentType(): String = when (this) {
is Event -> content.type
is Virtual -> model.type
is GroupedEvents -> "groupedEvent"
}
@Immutable
@ -60,4 +63,13 @@ sealed interface TimelineItem {
val safeSenderName: String = senderDisplayName ?: senderId.value
}
@Immutable
data class GroupedEvents(
val expanded: Boolean,
val events: ImmutableList<Event>,
) : TimelineItem {
// use first id with a suffix
val id = events.first().id + "_group"
}
}

View file

@ -65,3 +65,7 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
)
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent(
body = "A state event",
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2022 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.
@ -14,18 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
package io.element.android.features.messages.impl.timeline.model.event
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
}
data class TimelineItemProfileChangeContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemProfileChangeContent"
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
data class TimelineItemRoomMembershipContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemRoomMembershipContent"
}

View file

@ -14,10 +14,8 @@
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
interface RoomLastMessageFormatter {
fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence?
sealed interface TimelineItemStateContent : TimelineItemEventContent {
val body: String
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
data class TimelineItemStateEventContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemStateEventContent"
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d cambio en la sala"</item>
<item quantity="other">"%1$d cambios en la sala"</item>
</plurals>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d modifica alla stanza"</item>
<item quantity="other">"%1$d modifiche alla stanza"</item>
</plurals>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d schimbare a camerii"</item>
<item quantity="few">"%1$d schimbări ale camerei"</item>
<item quantity="other">"%1$d schimbări ale camerei"</item>
</plurals>
</resources>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>

View file

@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -138,6 +139,7 @@ class MessagesPresenterTest {
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = matrixRoom,
)
val actionListPresenter = ActionListPresenter()

View file

@ -31,26 +31,39 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.tests.testutils.testCoroutineDispatchers
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(),
profileChangeFactory = TimelineItemContentProfileChangeFactory(),
stateFactory = TimelineItemContentStateFactory(),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
)
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
internal fun aTimelineItemsFactory(): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
)
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
),
)
)
)
}
internal fun aTimelineEventFormatter(): TimelineEventFormatter {
return object : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence {
return ""
}
}
}

View file

@ -14,6 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.textcomposer
import app.cash.molecule.RecompositionClock
@ -50,6 +52,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest

View file

@ -23,8 +23,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -33,6 +38,7 @@ class TimelinePresenterTest {
fun `present - initial state`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
@ -49,6 +55,7 @@ class TimelinePresenterTest {
fun `present - load more`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
@ -71,6 +78,7 @@ class TimelinePresenterTest {
fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
@ -87,4 +95,37 @@ class TimelinePresenterTest {
assertThat(withoutHighlightedState.highlightedEventId).isNull()
}
}
@Test
fun `present - expand and collapse grouped events`() = runTest {
val fakeTimeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
)
)
val fakeRoom = FakeMatrixRoom(matrixTimeline = fakeTimeline)
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = fakeRoom,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
fakeTimeline.updateTimelineItems { it }
val loadedState = awaitItem()
val group1 = loadedState.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group1.expanded).isFalse()
loadedState.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group1))
val withExpandedGroup = awaitItem()
val group2 = withExpandedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group2.expanded).isTrue()
withExpandedGroup.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group2))
val withCollapsedGroup = awaitItem()
val group3 = withCollapsedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group3.expanded).isFalse()
}
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.timeline.groups
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value,
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList())
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@Test
fun `test empty`() {
val result = sut.group(emptyList(), emptyMap())
assertThat(result).isEmpty()
}
@Test
fun `test non groupables`() {
val result = sut.group(
listOf(
aNonGroupableItem,
aNonGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
aNonGroupableItem,
aNonGroupableItem,
)
)
}
@Test
fun `test groupables and ensure reordering`() {
val result = sut.group(
listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
).toImmutableList()
),
)
)
}
@Test
fun `test groupables expanded`() {
val result = sut.group(
listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
),
mapOf("${AN_EVENT_ID_2.value}_group" to true)
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = true,
events = listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
).toImmutableList()
),
)
)
}
@Test
fun `test 1 groupable, not group must be created`() {
val listsToTest = listOf(
listOf(aGroupableItem),
listOf(aGroupableItem, aNonGroupableItem),
listOf(aGroupableItem, aNonGroupableItemNoEvent),
listOf(aNonGroupableItem, aGroupableItem),
listOf(aNonGroupableItemNoEvent, aGroupableItem),
listOf(aNonGroupableItem, aGroupableItem, aNonGroupableItem),
listOf(aNonGroupableItemNoEvent, aGroupableItem, aNonGroupableItemNoEvent),
listOf(aGroupableItem, aNonGroupableItem, aGroupableItem),
listOf(aGroupableItem, aNonGroupableItemNoEvent, aGroupableItem),
listOf(aNonGroupableItem),
listOf(aNonGroupableItemNoEvent),
)
listsToTest.forEach { listToTest ->
val result = sut.group(listToTest, emptyMap())
assertThat(result).isEqualTo(listToTest)
}
}
@Test
fun `test 3 blocks`() {
val result = sut.group(
listOf(
aGroupableItem,
aGroupableItem,
aNonGroupableItem,
aGroupableItem,
aGroupableItem,
aGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem,
).toImmutableList()
),
aNonGroupableItem,
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem,
aGroupableItem,
).toImmutableList()
)
)
)
}
}

View file

@ -4,11 +4,13 @@
<item quantity="one">"1 Person"</item>
<item quantity="other">"%1$d Personen"</item>
</plurals>
<string name="screen_room_details_already_invited">"Bereits eingeladen"</string>
<string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_dm_details_block_alert_action">"Blockieren"</string>
<string name="screen_dm_details_block_user">"Nutzer blockieren"</string>
<string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string>
<string name="screen_room_details_invite_people_title">"Personen einladen"</string>
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string>
<string name="screen_room_details_topic_title">"Thema"</string>

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
@ -63,6 +64,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.networkmonitor.test)

View file

@ -1,330 +0,0 @@
/*
* 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.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is StickerContent -> {
content.body
}
is UnableToDecryptContent -> {
val message = context.getString(StringR.string.common_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.common_unsupported_event), 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.common_video)
}
is ImageMessageType -> {
context.getString(StringR.string.common_image)
}
is FileMessageType -> {
context.getString(StringR.string.common_file)
}
is AudioMessageType -> {
context.getString(StringR.string.common_audio)
}
UnknownMessageType -> {
context.getString(StringR.string.common_unsupported_event)
}
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(R.string.state_event_room_join_by_you)
} else {
context.getString(R.string.state_event_room_join, userId.value)
}
MembershipChange.LEFT -> if (memberIsYou) {
context.getString(R.string.state_event_room_leave_by_you)
} else {
context.getString(R.string.state_event_room_leave, userId.value)
}
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
context.getString(R.string.state_event_room_ban_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_ban, senderDisplayName, userId.value)
}
MembershipChange.UNBANNED -> if (senderIsYou) {
context.getString(R.string.state_event_room_unban_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_unban, senderDisplayName, userId.value)
}
MembershipChange.KICKED -> if (senderIsYou) {
context.getString(R.string.state_event_room_remove_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_remove, senderDisplayName, userId.value)
}
MembershipChange.INVITED -> if (senderIsYou) {
context.getString(R.string.state_event_room_invite_by_you, userId.value)
} else if (memberIsYou) {
context.getString(R.string.state_event_room_invite_you, senderDisplayName)
} else {
context.getString(R.string.state_event_room_invite, senderDisplayName, userId.value)
}
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_invite_accepted_by_you)
} else {
context.getString(R.string.state_event_room_invite_accepted, userId.value)
}
MembershipChange.INVITATION_REJECTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_reject_by_you)
} else {
context.getString(R.string.state_event_room_reject, userId.value)
}
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
context.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value)
}
MembershipChange.KNOCKED -> if (memberIsYou) {
context.getString(R.string.state_event_room_knock_by_you)
} else {
context.getString(R.string.state_event_room_knock, userId.value)
}
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
context.getString(R.string.state_event_room_knock_accepted_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value)
}
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_knock_retracted_by_you)
} else {
context.getString(R.string.state_event_room_knock_retracted, userId.value)
}
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
context.getString(R.string.state_event_room_knock_denied_by_you, userId.value)
} else if (memberIsYou) {
context.getString(R.string.state_event_room_knock_denied_you, senderDisplayName)
} else {
context.getString(R.string.state_event_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(R.string.state_event_room_avatar_changed_by_you)
senderIsYou && !hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_removed_by_you)
!senderIsYou && hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_changed, senderDisplayName)
else -> context.getString(R.string.state_event_room_avatar_removed, senderDisplayName)
}
}
is OtherState.RoomCreate -> {
if (senderIsYou) {
context.getString(R.string.state_event_room_created_by_you)
} else {
context.getString(R.string.state_event_room_created, senderDisplayName)
}
}
is OtherState.RoomEncryption -> context.getString(StringR.string.common_encryption_enabled)
is OtherState.RoomName -> {
val hasRoomName = content.name != null
when {
senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed_by_you, content.name)
senderIsYou && !hasRoomName -> context.getString(R.string.state_event_room_name_removed_by_you)
!senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name)
else -> context.getString(R.string.state_event_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(R.string.state_event_room_third_party_invite_by_you, content.displayName)
} else {
context.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName)
}
}
is OtherState.RoomTopic -> {
val hasRoomTopic = content.topic != null
when {
senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed_by_you, content.topic)
senderIsYou && !hasRoomTopic -> context.getString(R.string.state_event_room_topic_removed_by_you)
!senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic)
else -> context.getString(R.string.state_event_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(R.string.state_event_avatar_changed_too)
"$message\n$avatarChangedToo"
}
displayNameChanged -> {
if (displayName != null && prevDisplayName != null) {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName)
} else {
context.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName)
}
} else if (displayName != null) {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_set_by_you, displayName)
} else {
context.getString(R.string.state_event_display_name_set, senderDisplayName, displayName)
}
} else {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName)
} else {
context.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName)
}
}
}
avatarChanged -> {
if (senderIsYou) {
context.getString(R.string.state_event_avatar_url_changed_by_you)
} else {
context.getString(R.string.state_event_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)
}
}
}

View file

@ -39,6 +39,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -199,7 +200,7 @@ class RoomListPresenter @Inject constructor(
hasUnread = roomSummary.details.unreadNotificationCount > 0,
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
lastMessage = roomSummary.details.lastMessage?.let { message ->
roomLastMessageFormatter.processMessageItem(message.event, roomSummary.details.isDirect)
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(),
avatarData = avatarData,
)

View file

@ -1,40 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_main_space_title">"Alle Chats"</string>
<string name="state_event_avatar_changed_too">"(Avatar wurde ebenfalls geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat seinen Avatar geändert"</string>
<string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string>
<string name="state_event_display_name_changed_from">"%1$s hat den Anzeigenamen von %2$s in %3$s geändert"</string>
<string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s in %2$s geändert"</string>
<string name="state_event_display_name_removed">"%1$s hat den Anzeigenamen entfernt (war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat den Anzeigenamen auf %2$s gesetzt"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s gesetzt"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Du hast den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Raum-Avatar entfernt"</string>
<string name="state_event_room_created">"%1$s hat den Raum erstellt"</string>
<string name="state_event_room_created_by_you">"Du hast den Raum erstellt"</string>
<string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string>
<string name="state_event_room_invite_accepted">"%1$s hat die Einladung angenommen"</string>
<string name="state_event_room_invite_accepted_by_you">"Du hast die Einladung angenommen"</string>
<string name="state_event_room_invite_by_you">"Du hast %1$s eingeladen"</string>
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s ist dem Raum beigetreten"</string>
<string name="state_event_room_join_by_you">"Du bist dem Raum beigetreten"</string>
<string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string>
<string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string>
<string name="state_event_room_leave_by_you">"Du hast den Raum verlassen"</string>
<string name="state_event_room_name_changed">"%1$s hat den Raumnamen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Sie haben den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Raumnamen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Du hast den Raumnamen entfernt"</string>
<string name="state_event_room_reject">"%1$s hat die Einladung abgelehnt"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
<string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string>
<string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string>
<string name="state_event_room_topic_changed">"%1$s hat das Thema geändert zu: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sie haben das Thema geändert zu: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Raumthema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Du hast das Raumthema entfernt"</string>
</resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Todos los chats"</string>
<string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string>
<string name="session_verification_banner_title">"Accede a tu historial de mensajes"</string>
<string name="state_event_avatar_changed_too">"(el avatar también cambió)"</string>
<string name="state_event_avatar_url_changed">"%1$s cambió su avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Cambiaste tu avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s cambió su nombre de %2$s a %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Cambiaste tu nombre de %1$s a %2$s"</string>
<string name="state_event_display_name_removed">"%1$s eliminó su nombre (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Eliminaste tu nombre (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s cambió su nombre a %2$s"</string>
<string name="state_event_display_name_set_by_you">"Cambiaste tu nombre a %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s cambió el avatar de la sala"</string>
<string name="state_event_room_avatar_changed_by_you">"Cambiaste el avatar de la sala"</string>
<string name="state_event_room_avatar_removed">"%1$s eliminó el avatar de la sala"</string>
<string name="state_event_room_avatar_removed_by_you">"Eliminaste el avatar de la sala"</string>
<string name="state_event_room_ban">"%1$s expulsó permanentemente a %2$s"</string>
<string name="state_event_room_ban_by_you">"Expulsaste permanentemente a %1$s"</string>
<string name="state_event_room_created">"%1$s creó la sala"</string>
<string name="state_event_room_created_by_you">"Tú creaste la sala"</string>
<string name="state_event_room_invite">"%1$s invitó a %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s aceptó la invitación"</string>
<string name="state_event_room_invite_accepted_by_you">"Aceptaste la invitación"</string>
<string name="state_event_room_invite_by_you">"Invitaste a %1$s"</string>
<string name="state_event_room_invite_you">"%1$s te invitó."</string>
<string name="state_event_room_join">"%1$s se unió a la sala"</string>
<string name="state_event_room_join_by_you">"Te uniste a la sala"</string>
<string name="state_event_room_knock">"%1$s solicitó unirse"</string>
<string name="state_event_room_knock_accepted">"%1$s permitió que %2$s se uniera"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s te permitió unirte"</string>
<string name="state_event_room_knock_by_you">"Solicitaste unirte"</string>
<string name="state_event_room_knock_denied">"%1$s rechazó la solicitud de %2$s para unirse"</string>
<string name="state_event_room_knock_denied_by_you">"Rechazaste la solicitud de %1$s para unirte"</string>
<string name="state_event_room_knock_denied_you">"%1$s rechazó su solicitud para unirte"</string>
<string name="state_event_room_knock_retracted">"%1$s ya no está interesado en unirse"</string>
<string name="state_event_room_knock_retracted_by_you">"Cancelaste tu solicitud de unirte"</string>
<string name="state_event_room_leave">"%1$s salió de la sala"</string>
<string name="state_event_room_leave_by_you">"Saliste de la sala"</string>
<string name="state_event_room_name_changed">"%1$s cambió el nombre de la sala a: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Cambiaste el nombre de la sala a: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s eliminó el nombre de la sala"</string>
<string name="state_event_room_name_removed_by_you">"Eliminaste el nombre de la sala"</string>
<string name="state_event_room_reject">"%1$s rechazó la invitación"</string>
<string name="state_event_room_reject_by_you">"Rechazaste la invitación"</string>
<string name="state_event_room_remove">"%1$s echó a %2$s"</string>
<string name="state_event_room_remove_by_you">"Echaste a %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s envió una invitación a %2$s para unirse a la sala"</string>
<string name="state_event_room_third_party_invite_by_you">"Enviaste una invitación a %1$s para unirse a la sala"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s revocó la invitación a %2$s para unirse a la sala"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Revocaste la invitación de %1$s para unirse a la sala"</string>
<string name="state_event_room_topic_changed">"%1$s cambió el tema a: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Cambiaste el tema a: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s eliminó el tema de la sala"</string>
<string name="state_event_room_topic_removed_by_you">"Eliminaste el tema de la sala"</string>
<string name="state_event_room_unban">"%1$s readmitió a %2$s"</string>
<string name="state_event_room_unban_by_you">"Readmitiste a %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s realizó un cambio desconocido en su membresía"</string>
</resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Tutte le conversazioni"</string>
<string name="session_verification_banner_message">"Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati."</string>
<string name="session_verification_banner_title">"Accedi alla cronologia dei messaggi"</string>
<string name="state_event_avatar_changed_too">"(anche l\'avatar è stato cambiato)"</string>
<string name="state_event_avatar_url_changed">"%1$s ha cambiato il proprio avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Hai cambiato il tuo avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Hai cambiato il tuo nome visualizzato da %1$s a %2$s"</string>
<string name="state_event_display_name_removed">"%1$s ha rimosso il proprio nome visualizzato (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Hai rimosso il tuo nome visualizzato (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s ha impostato il proprio nome visualizzato su %2$s"</string>
<string name="state_event_display_name_set_by_you">"Hai impostato il tuo nome visualizzato su %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s ha cambiato l\'avatar della stanza"</string>
<string name="state_event_room_avatar_changed_by_you">"Hai cambiato l\'avatar della stanza"</string>
<string name="state_event_room_avatar_removed">"%1$s ha rimosso l\'avatar della stanza"</string>
<string name="state_event_room_avatar_removed_by_you">"Hai rimosso l\'avatar della stanza"</string>
<string name="state_event_room_ban">"%1$s ha rimosso %2$s"</string>
<string name="state_event_room_ban_by_you">"Hai rimosso %1$s"</string>
<string name="state_event_room_created">"%1$s ha creato la stanza"</string>
<string name="state_event_room_created_by_you">"Hai creato la stanza"</string>
<string name="state_event_room_invite">"%1$s ha invitato %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s ha accettato l\'invito"</string>
<string name="state_event_room_invite_accepted_by_you">"Hai accettato l\'invito"</string>
<string name="state_event_room_invite_by_you">"Hai invitato %1$s"</string>
<string name="state_event_room_invite_you">"%1$s ti ha invitato"</string>
<string name="state_event_room_join">"%1$s si è unito alla stanza"</string>
<string name="state_event_room_join_by_you">"Ti sei unito alla stanza"</string>
<string name="state_event_room_knock">"%1$s ha chiesto di unirsi"</string>
<string name="state_event_room_knock_accepted">"%1$s ha permesso a %2$s di unirsi"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s ti ha permesso di unirti"</string>
<string name="state_event_room_knock_by_you">"Hai richiesto di unirti"</string>
<string name="state_event_room_knock_denied">"%1$s ha rifiutato la richiesta di unirsi di %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Hai rifiutato la richiesta di unirsi di %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s ha rifiutato la tua richiesta di unirti"</string>
<string name="state_event_room_knock_retracted">"%1$s non è più interessato a partecipare"</string>
<string name="state_event_room_knock_retracted_by_you">"Hai annullato la tua richiesta di unirti"</string>
<string name="state_event_room_leave">"%1$s ha lasciato la stanza"</string>
<string name="state_event_room_leave_by_you">"Hai lasciato la stanza"</string>
<string name="state_event_room_name_changed">"%1$s ha cambiato il nome della stanza in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Hai cambiato il nome della stanza in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s ha rimosso il nome della stanza"</string>
<string name="state_event_room_name_removed_by_you">"Hai rimosso il nome della stanza"</string>
<string name="state_event_room_reject">"%1$s ha rifiutato l\'invito"</string>
<string name="state_event_room_reject_by_you">"Hai rifiutato l\'invito"</string>
<string name="state_event_room_remove">"%1$s ha rimosso %2$s"</string>
<string name="state_event_room_remove_by_you">"Hai rimosso %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s ha inviato un invito a %2$s per unirsi alla stanza"</string>
<string name="state_event_room_third_party_invite_by_you">"Hai inviato un invito a %1$s per unirsi alla stanza"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza."</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Hai revocato l\'invito a %1$s a universi alla stanza"</string>
<string name="state_event_room_topic_changed">"%1$s ha cambiato l\'oggetto in: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Hai cambiato l\'oggetto in: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s ha rimosso l\'oggetto della stanza"</string>
<string name="state_event_room_topic_removed_by_you">"Hai rimosso l\'oggetto della stanza"</string>
<string name="state_event_room_unban">"%1$s ha sbloccato %2$s"</string>
<string name="state_event_room_unban_by_you">"Hai sbloccato %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s ha apportato una modifica sconosciuta alla propria iscrizione"</string>
</resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Toate conversatiile"</string>
<string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."</string>
<string name="session_verification_banner_title">"Accesați istoricul mesajelor"</string>
<string name="state_event_avatar_changed_too">"(s-a schimbat si avatarul)"</string>
<string name="state_event_avatar_url_changed">"%1$s și-a schimbat avatarul"</string>
<string name="state_event_avatar_url_changed_by_you">"V-ați schimbat avatarul"</string>
<string name="state_event_display_name_changed_from">"%1$s și-a schimbat numele din %2$s în %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"V-ați schimbat numele din %1$s în %2$s"</string>
<string name="state_event_display_name_removed">"%1$s și-a sters numele (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"V-ați sters numele (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s și-a schimbat numele %2$s"</string>
<string name="state_event_display_name_set_by_you">"V-ați schimbat numele în %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s a schimbat avatarul camerei"</string>
<string name="state_event_room_avatar_changed_by_you">"Ați schimbat avatarul camerei"</string>
<string name="state_event_room_avatar_removed">"%1$s a șters avatarul camerei"</string>
<string name="state_event_room_avatar_removed_by_you">"Ați șters avatarul camerei"</string>
<string name="state_event_room_ban">"%1$s a adăugat o interdicție pentru %2$s"</string>
<string name="state_event_room_ban_by_you">"Ați adăugat o interdicție pentru %1$s"</string>
<string name="state_event_room_created">"%1$s a creat camera"</string>
<string name="state_event_room_created_by_you">"Ați creat camera"</string>
<string name="state_event_room_invite">"%1$s l-a invitat pe %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s a acceptat invitația"</string>
<string name="state_event_room_invite_accepted_by_you">"Ați acceptat invitația"</string>
<string name="state_event_room_invite_by_you">"L-ați invitat pe %1$s"</string>
<string name="state_event_room_invite_you">"%1$s v-a invitat"</string>
<string name="state_event_room_join">"%1$s a intrat în cameră"</string>
<string name="state_event_room_join_by_you">"Ați intrat în cameră"</string>
<string name="state_event_room_knock">"%1$s a solicitat să se alăture camerei"</string>
<string name="state_event_room_knock_accepted">"%1$s i-a permis lui %2$s să se alăture camerei"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s v-a permis să vă alăturați camerei"</string>
<string name="state_event_room_knock_by_you">"Ați solicitat să vă alăturați camerei"</string>
<string name="state_event_room_knock_denied">"%1$s a respins solicitarea de alăturare a lui %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Ați respins solicitarea de alăturare a lui %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s a respins cererea dumneavoastră de alăturare"</string>
<string name="state_event_room_knock_retracted">"%1$s nu mai este interesat să se alăture camerei"</string>
<string name="state_event_room_knock_retracted_by_you">"Ați anulat cererea de alăturare"</string>
<string name="state_event_room_leave">"%1$s a părăsit camera"</string>
<string name="state_event_room_leave_by_you">"Ați părăsit camera"</string>
<string name="state_event_room_name_changed">"%1$s a schimbat numele camerei în: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Ați schimbat numele camerei în: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s a sters numele camerei"</string>
<string name="state_event_room_name_removed_by_you">"Ați șters numele camerei"</string>
<string name="state_event_room_reject">"%1$s a respins invitația"</string>
<string name="state_event_room_reject_by_you">"Ați respins invitația"</string>
<string name="state_event_room_remove">"%1$s l-a îndepărtat pe %2$s"</string>
<string name="state_event_room_remove_by_you">"L-ați îndepărtat pe %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s a trimis o invitație către %2$s pentru a se alătura camerei"</string>
<string name="state_event_room_third_party_invite_by_you">"Ați trimis o invitație către %1$s pentru a se alătura camerei"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s a revocat invitația pentru %2$s de a se alătura camerei"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Ați revocat invitația pentru %1$s de a se alătura camerei"</string>
<string name="state_event_room_topic_changed">"%1$s a schimbat subiectul în: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Ați schimbat subiectul în: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s a șters subiectul camerei"</string>
<string name="state_event_room_topic_removed_by_you">"Ați șters subiectul camerei"</string>
<string name="state_event_room_unban">"%1$s a anulat interdicția pentru %2$s"</string>
<string name="state_event_room_unban_by_you">"Ați anulat interdicția pentru %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s a făcut o modificare necunoscută asupra calității sale de membru"</string>
</resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"All Chats"</string>
<string name="session_verification_banner_message">"Looks like youre using a new device. Verify its you to access your encrypted messages."</string>
<string name="session_verification_banner_title">"Access your message history"</string>
<string name="state_event_avatar_changed_too">"(avatar was changed too)"</string>
<string name="state_event_avatar_url_changed">"%1$s changed their avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"You changed your avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s changed their display name from %2$s to %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"You changed your display name from %1$s to %2$s"</string>
<string name="state_event_display_name_removed">"%1$s removed their display name (it was %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"You removed your display name (it was %1$s)"</string>
<string name="state_event_display_name_set">"%1$s set their display name to %2$s"</string>
<string name="state_event_display_name_set_by_you">"You set your display name to %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s changed the room avatar"</string>
<string name="state_event_room_avatar_changed_by_you">"You changed the room avatar"</string>
<string name="state_event_room_avatar_removed">"%1$s removed the room avatar"</string>
<string name="state_event_room_avatar_removed_by_you">"You removed the room avatar"</string>
<string name="state_event_room_ban">"%1$s banned %2$s"</string>
<string name="state_event_room_ban_by_you">"You banned %1$s"</string>
<string name="state_event_room_created">"%1$s created the room"</string>
<string name="state_event_room_created_by_you">"You created the room"</string>
<string name="state_event_room_invite">"%1$s invited %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s accepted the invite"</string>
<string name="state_event_room_invite_accepted_by_you">"You accepted the invite"</string>
<string name="state_event_room_invite_by_you">"You invited %1$s"</string>
<string name="state_event_room_invite_you">"%1$s invited you"</string>
<string name="state_event_room_join">"%1$s joined the room"</string>
<string name="state_event_room_join_by_you">"You joined the room"</string>
<string name="state_event_room_knock">"%1$s requested to join"</string>
<string name="state_event_room_knock_accepted">"%1$s allowed %2$s to join"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s allowed you to join"</string>
<string name="state_event_room_knock_by_you">"You requested to join"</string>
<string name="state_event_room_knock_denied">"%1$s rejected %2$s\'s request to join"</string>
<string name="state_event_room_knock_denied_by_you">"You rejected %1$s\'s request to join"</string>
<string name="state_event_room_knock_denied_you">"%1$s rejected your request to join"</string>
<string name="state_event_room_knock_retracted">"%1$s is no longer interested in joining"</string>
<string name="state_event_room_knock_retracted_by_you">"You cancelled your request to join"</string>
<string name="state_event_room_leave">"%1$s left the room"</string>
<string name="state_event_room_leave_by_you">"You left the room"</string>
<string name="state_event_room_name_changed">"%1$s changed the room name to: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"You changed the room name to: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s removed the room name"</string>
<string name="state_event_room_name_removed_by_you">"You removed the room name"</string>
<string name="state_event_room_reject">"%1$s rejected the invitation"</string>
<string name="state_event_room_reject_by_you">"You rejected the invitation"</string>
<string name="state_event_room_remove">"%1$s removed %2$s"</string>
<string name="state_event_room_remove_by_you">"You removed %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s sent an invitation to %2$s to join the room"</string>
<string name="state_event_room_third_party_invite_by_you">"You sent an invitation to %1$s to join the room"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s revoked the invitation for %2$s to join the room"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"You revoked the invitation for %1$s to join the room"</string>
<string name="state_event_room_topic_changed">"%1$s changed the topic to: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"You changed the topic to: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s removed the room topic"</string>
<string name="state_event_room_topic_removed_by_you">"You removed the room topic"</string>
<string name="state_event_room_unban">"%1$s unbanned %2$s"</string>
<string name="state_event_room_unban_by_you">"You unbanned %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s made an unknown change to their membership"</string>
</resources>

View file

@ -1,757 +0,0 @@
/*
* 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<Pair<MessageType, CharSequence?>>()
val resultsInDm = mutableListOf<Pair<MessageType, CharSequence?>>()
// 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 -> "Unsupported event"
}
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: Unsupported event"
}
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:domain"), 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} 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:domain"), 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} left the room")
}
@Test
@Config(qualifiers = "en")
fun `Membership change - banned`() {
val otherName = "Someone"
val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED)
val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED)
val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED)
val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), 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}")
val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent)
val youKickedBanned = formatter.processMessageItem(youKickBannedEvent, false)
Truth.assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId}")
val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneBanned = formatter.processMessageItem(someoneBannedEvent, false)
Truth.assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId}")
val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent)
val someoneKickBanned = formatter.processMessageItem(someoneKickBannedEvent, false)
Truth.assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId}")
}
@Test
@Config(qualifiers = "en")
fun `Membership change - unban`() {
val otherName = "Someone"
val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED)
val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), 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}")
val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneUnbanned = formatter.processMessageItem(someoneUnbannedEvent, false)
Truth.assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId}")
}
@Test
@Config(qualifiers = "en")
fun `Membership change - kicked`() {
val otherName = "Someone"
val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED)
val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), 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}")
val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneKicked = formatter.processMessageItem(someoneKickedEvent, false)
Truth.assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId}")
}
@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:domain"), 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}")
val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneInvited = formatter.processMessageItem(someoneInvitedEvent, false)
Truth.assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId}")
}
@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:domain"), 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} 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:domain"), 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} rejected the invitation")
}
@Test
@Config(qualifiers = "en")
fun `Membership change - invitation revoked`() {
val otherName = "Someone"
val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), 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} 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} 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:domain"), 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} requested to join")
}
@Test
@Config(qualifiers = "en")
fun `Membership change - knock accepted`() {
val otherName = "Someone"
val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_ACCEPTED)
val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
val youAcceptedKnock = formatter.processMessageItem(youAcceptedKnockEvent, false)
Truth.assertThat(youAcceptedKnock).isEqualTo("${someoneContent.userId} 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} 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:domain"), 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} 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:domain"), 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}'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}'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:domain")
val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null)
return anEventTimelineItem(content = content, senderProfile = profile, sender = sender)
}
}

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION