feat: Update call started timeline item + declined support

This commit is contained in:
Valere 2026-04-24 11:37:00 +02:00
parent 8d7cc14096
commit f09c1023f7
9 changed files with 138 additions and 42 deletions

View file

@ -22,17 +22,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.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.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.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -42,6 +44,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun TimelineItemCallNotifyView( internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event, event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent, content: TimelineItemRtcNotificationContent,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
@ -62,37 +65,22 @@ internal fun TimelineItemCallNotifyView(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Avatar( Icon(
avatarData = event.senderAvatar, modifier = Modifier.size(20.sp.toDp()),
avatarType = AvatarType.User, imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
) )
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = event.safeSenderName, modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, text = stringResource(getTextRes(timelineRoomInfo, content)),
maxLines = 1, style = ElementTheme.typography.fontBodyMdRegular,
overflow = TextOverflow.Ellipsis, color = ElementTheme.colors.textSecondary,
) maxLines = 1,
Row( overflow = TextOverflow.Ellipsis,
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( Text(
text = event.sentTime, text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
@ -103,15 +91,51 @@ internal fun TimelineItemCallNotifyView(
} }
} }
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
RtcNotificationState.Declined -> CommonStrings.common_call_declined
RtcNotificationState.DeclinedByMe -> CommonStrings.common_call_you_declined
RtcNotificationState.None -> CommonStrings.common_call_started
}
} else {
// Only show declined info in DMs
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && (
content.state == RtcNotificationState.Declined ||
content.state == RtcNotificationState.DeclinedByMe
)
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 @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview { internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
listOf( listOf(
TimelineItemRtcNotificationContent(CallIntent.AUDIO), (aTimelineRoomInfo() to TimelineItemRtcNotificationContent(CallIntent.AUDIO, RtcNotificationState.None)),
TimelineItemRtcNotificationContent(CallIntent.VIDEO), (aTimelineRoomInfo() to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.None)),
).forEach { content -> (aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.AUDIO, RtcNotificationState.Declined)),
(aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.Declined)),
(aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.DeclinedByMe)),
(aTimelineRoomInfo(isDm = false) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.None)),
).forEach { (info, content) ->
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
timelineRoomInfo = info,
event = aTimelineItemEvent(content = content), event = aTimelineItemEvent(content = content),
content = content, content = content,
onLongClick = {}, onLongClick = {},

View file

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

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent 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 PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent( is CallNotifyContent -> TimelineItemRtcNotificationContent(
itemContent.callIntent callIntent = itemContent.callIntent,
state = when {
itemContent.declinedBy.isEmpty().not() && itemContent.declinedBy.any { it == sessionId } -> RtcNotificationState.DeclinedByMe
itemContent.declinedBy.isEmpty().not() -> RtcNotificationState.Declined
else -> RtcNotificationState.None
}
) )
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> { is LiveLocationContent -> {

View file

@ -9,7 +9,24 @@
package io.element.android.features.messages.impl.timeline.model.event 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.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent { // For now this is just an enum, but could be a
override val type: String = "org.matrix.msc4075.rtc.notification" // sealed class if we need the list of users who declined.
enum class RtcNotificationState {
/** Some users have declined */
Declined,
/** I have declined this call */
DeclinedByMe,
// Future sates could be `Missed`? `ongoing`...
None
}
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 android.content.Context
import dev.zacsweers.metro.ContributesBinding 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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -56,7 +57,12 @@ class DefaultMessageSummaryFormatter(
is TimelineItemFileContent -> context.getString(CommonStrings.common_file) is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started) is TimelineItemRtcNotificationContent -> when (content.state) {
RtcNotificationState.Declined ->
context.getString(CommonStrings.common_call_declined)
RtcNotificationState.DeclinedByMe -> context.getString(CommonStrings.common_call_you_declined)
RtcNotificationState.None -> context.getString(CommonStrings.common_call_started)
}
} }
// Truncate the message to a safe length to avoid crashes in Compose // Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength() .toSafeLength()

View file

@ -55,6 +55,7 @@ class DefaultRoomLatestEventFormatter(
private val profileChangeContentFormatter: ProfileChangeContentFormatter, private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter, private val stateContentFormatter: StateContentFormatter,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val rtcNotificationContentFormatter: RtcNotificationContentFormatter,
) : RoomLatestEventFormatter { ) : RoomLatestEventFormatter {
override fun format( override fun format(
latestEvent: LatestEventValue.Local, latestEvent: LatestEventValue.Local,
@ -121,7 +122,7 @@ class DefaultRoomLatestEventFormatter(
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
} }
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) 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) }?.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

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

View file

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