Merge pull request #6649 from element-hq/feature/valere/call/decline_timeline_rendering

feat: Update call started timeline item + declined support
This commit is contained in:
Valere Fedronic 2026-05-12 16:32:51 +02:00 committed by GitHub
commit 9ca0b9e898
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 360 additions and 56 deletions

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -22,17 +23,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
onLongClick: (TimelineItem.Event) -> Unit,
@ -62,37 +66,22 @@ internal fun TimelineItemCallNotifyView(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = event.senderAvatar,
avatarType = AvatarType.User,
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = event.safeSenderName,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector =
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = stringResource(CommonStrings.common_call_started),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular,
@ -103,19 +92,56 @@ internal fun TimelineItemCallNotifyView(
}
}
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
val icon = if (showAsDeclined) {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
} else {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
}
return icon
}
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
listOf(
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
).forEach { content ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
listOf(false, true).forEach { isDm ->
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Started,
RtcNotificationState.Declined(byMe = false),
RtcNotificationState.Declined(byMe = true),
).forEach { state ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
}
}
}
}
}

View file

@ -125,6 +125,7 @@ internal fun TimelineItemRow(
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
onLongClick = onLongClick,

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -104,7 +105,12 @@ class TimelineItemContentFactory(
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent(
itemContent.callIntent
callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
)
is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> {

View file

@ -9,7 +9,19 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
override val type: String = "org.matrix.msc4075.rtc.notification"
// State of the call, for now only isDeclined but in the future could be missed, active.
sealed interface RtcNotificationState {
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState
object Started : RtcNotificationState
}
class TimelineItemRtcNotificationContent(
val callIntent: CallIntent,
val state: RtcNotificationState,
) : TimelineItemEventContent {
override val type: String = EventType.RTC_NOTIFICATION
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -56,7 +57,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) {
context.getString(CommonStrings.common_call_you_declined)
} else {
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
}
}
// Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength()

View file

@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO),
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.utils
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent.Mode
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
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 DefaultMessageSummaryFormatterTest {
private val formatter = DefaultMessageSummaryFormatter(
RuntimeEnvironment.getApplication() as Context
)
@Test
@Config(qualifiers = "en")
fun `format call notification started`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Started
)
)
assertThat(expected).isEqualTo("Call started")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined by me`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = true)
)
)
assertThat(expected).isEqualTo("You declined a call")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = false)
)
)
assertThat(expected).isEqualTo("Call declined")
}
@Test
@Config(qualifiers = "en")
fun `format live location`() {
val expected = formatter.format(
aLocationContent(isLive = true)
)
assertThat(expected).isEqualTo("Shared live location")
}
@Test
@Config(qualifiers = "en")
fun `format static location`() {
val expected = formatter.format(
aLocationContent(isLive = false)
)
assertThat(expected).isEqualTo("Shared location")
}
}
private fun aLocationContent(isLive: Boolean) = TimelineItemLocationContent(
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
description = null,
assetType = null,
mode = if (isLive) {
Mode.Live(
lastKnownLocation = Location.fromGeoUri("geo:1,5"),
isActive = true,
endsAt = "",
endTimestamp = 0,
isOwnUser = true,
)
} else {
Mode.Static(
location = Location.fromGeoUri("geo:1,5")!!,
)
}
)

View file

@ -54,6 +54,7 @@ class DefaultRoomLatestEventFormatter(
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter,
private val rtcNotificationContentFormatter: RtcNotificationContentFormatter,
private val permalinkParser: PermalinkParser,
) : RoomLatestEventFormatter {
override fun format(
@ -121,7 +122,7 @@ class DefaultRoomLatestEventFormatter(
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
is CallNotifyContent -> rtcNotificationContentFormatter.format(content, isDmRoom)
}?.take(DEFAULT_SAFE_LENGTH)
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.eventformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@Inject
class RtcNotificationContentFormatter(
private val matrixClient: MatrixClient,
private val sp: StringProvider,
) {
fun format(
content: CallNotifyContent,
isDm: Boolean,
): CharSequence {
return if (isDm) {
val isDeclined = content.declinedBy.isNotEmpty()
val isDeclinedByMe = content.declinedBy.any { matrixClient.isMe(it) }
if (isDeclinedByMe) {
sp.getString(CommonStrings.common_call_you_declined)
} else if (isDeclined) {
sp.getString(CommonStrings.common_call_declined)
} else {
sp.getString(CommonStrings.common_call_started)
}
} else {
sp.getString(CommonStrings.common_call_started)
}
}
}

View file

@ -74,7 +74,8 @@ class DefaultRoomLatestEventFormatterTest {
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider),
permalinkParser = FakePermalinkParser(),
rtcNotificationContentFormatter = RtcNotificationContentFormatter(fakeMatrixClient, stringProvider),
permalinkParser = FakePermalinkParser()
)
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.eventformatter.impl
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
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
import kotlin.toString
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class RtcNotificationContentFormatterTest {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var formatter: RtcNotificationContentFormatter
@Before
fun setup() {
context = RuntimeEnvironment.getApplication() as Context
fakeMatrixClient = FakeMatrixClient()
val stringProvider = AndroidStringProvider(context.resources)
formatter = RtcNotificationContentFormatter(
fakeMatrixClient,
stringProvider
)
}
@Test
@Config(qualifiers = "en")
fun `Should not display declined info in rooms`() {
val result = formatter.format(
CallNotifyContent(
CallIntent.VIDEO,
declinedBy = listOf(A_USER_ID_2, A_USER_ID_3)
),
false
)
val expected = "Call started"
assertThat(result.toString()).isEqualTo(expected)
}
@Test
@Config(qualifiers = "en")
fun `Declined by me variant`() {
val result = formatter.format(
CallNotifyContent(
CallIntent.VIDEO,
declinedBy = listOf(fakeMatrixClient.sessionId)
),
true
)
val expected = "You declined a call"
assertThat(result.toString()).isEqualTo(expected)
}
@Test
@Config(qualifiers = "en")
fun `Declined by other variant`() {
val result = formatter.format(
CallNotifyContent(
CallIntent.VIDEO,
declinedBy = listOf(A_USER_ID_2)
),
true
)
val expected = "Call declined"
assertThat(result.toString()).isEqualTo(expected)
}
@Test
@Config(qualifiers = "en")
fun `Call started in DM`() {
val result = formatter.format(
CallNotifyContent(
CallIntent.AUDIO,
declinedBy = listOf()
),
true
)
val expected = "Call started"
assertThat(result.toString()).isEqualTo(expected)
}
}

View file

@ -119,7 +119,8 @@ data class LiveLocationContent(
data object LegacyCallInviteContent : EventContent
data class CallNotifyContent(
val callIntent: CallIntent
val callIntent: CallIntent,
val declinedBy: List<UserId>
) : EventContent
data object UnknownContent : EventContent

View file

@ -153,7 +153,8 @@ class TimelineEventContentMapper(
CallIntent.AUDIO
} else {
CallIntent.VIDEO
}
},
declinedBy = it.declinedBy.map(::UserId)
)
}
}

View file

@ -62,7 +62,7 @@ class DefaultEventItemFactoryTest {
fun `create check all null cases`() {
val factory = createEventItemFactory()
val contents = listOf(
CallNotifyContent(callIntent = CallIntent.VIDEO),
CallNotifyContent(callIntent = CallIntent.VIDEO, emptyList()),
FailedToParseMessageLikeContent("", ""),
FailedToParseStateContent("", "", ""),
LegacyCallInviteContent,

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3624fe8448ae4af2481e2023a978ffab2d69f2784b7cef41e5ae2e2dbe8fdbd5
size 17804
oid sha256:5529e89e00208e38522f5206f5b8d304bd472b27071cab4e0d3c2daf3cb64db0
size 49741

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bd02d39619efbcaa6d96f1e75a0d14a572c43e60bad0c3f84d6a5a48b6fbda1
size 17395
oid sha256:fa29aaa82f21912dd5147ffb6fdc457fd5880e8be4abde4f0177d0ab1a412fe2
size 48245