feat(call): Active call timeline rendering

This commit is contained in:
Valere
2026-06-18 12:16:31 +02:00
parent 405fd3ca0d
commit ddd72ec526
16 changed files with 471 additions and 25 deletions
@@ -283,6 +283,7 @@ fun MessagesView(
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
},
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
@@ -462,6 +463,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
@@ -516,6 +518,7 @@ private fun MessagesViewContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
@@ -234,6 +234,7 @@ private fun PinnedMessagesListLoaded(
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
eventSink = { timelineItemEvent ->
when (timelineItemEvent) {
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
@@ -103,6 +103,7 @@ fun TimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
@@ -185,6 +186,7 @@ fun TimelineView(
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
}
@@ -426,6 +428,7 @@ internal fun TimelineViewPreview(
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
forceJumpToBottomVisibility = true,
)
}
@@ -0,0 +1,373 @@
/*
* 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.timeline.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
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.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun ActiveCallTimelineItemView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
state: RtcNotificationState.Active,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(1.seconds)
currentTime = System.currentTimeMillis()
}
}
val elapsedMillis = (currentTime - state.callStartTsMillis).coerceAtLeast(0)
val formattedDuration = elapsedMillis.toHumanReadableDuration()
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractivePrimary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp)
.size(40.dp)
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(8.dp))
.padding(10.dp),
imageVector = if (state.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
if (timelineRoomInfo.isDm) {
DirectMessageCallBody(state, Modifier.weight(1f))
} else {
GroupCallBody(
event,
state,
Modifier.weight(1f)
.padding(top = 12.dp, bottom = 12.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp)
) {
if (!state.isJoined) {
Button(
text = "Join",
size = ButtonSize.Small,
onClick = { onJoinCallClick(state.callIntent == CallIntent.AUDIO) }
)
}
Text(
text = "($formattedDuration)",
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun DirectMessageCallBody(state: RtcNotificationState.Active, modifier: Modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = "${caller.displayName ?: caller.userId} started a call",
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
AvatarRow(
avatarDataList = state.joinedMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState.Active, modifier: Modifier) {
Column(modifier = modifier) {
Text(
text = stringResource(CommonStrings.common_group_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
// Only show started a call if the last remaining user in the call
// is really the one who started the call
if (caller.userId == event.senderId) {
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else {
val membersToShowCount = 4
// Keep the one with avatar first but maintain
// the first user as it might be the call creator
val sortedMembers = remember(state.joinedMembers) {
if (state.joinedMembers.isEmpty()) return@remember emptyList()
val first = state.joinedMembers.first()
val rest = state.joinedMembers.drop(1).sortedByDescending { it.avatarUrl != null }
listOf(first) + rest
}
val displayMembers = sortedMembers.take(membersToShowCount)
val extraCount = state.joinedMembers.size - membersToShowCount
AvatarRow(
avatarDataList = displayMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
if (extraCount > 0) {
Text(
text = pluralStringResource(CommonPlurals.screen_timeline_active_call_extra_joined_count, count = state.joinedMembers.size, extraCount),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
@PreviewsDayNight
@PreviewWithLargeHeight
@Composable
internal fun ActiveCallTimelineItemViewPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar
) {
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Active(
listOf(aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl")),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl"),
aMatrixUser(displayName = USER_NAME_BOB)
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE),
aMatrixUser(displayName = USER_NAME_BOB, avatarUrl = "anUrlB"),
aMatrixUser(displayName = USER_NAME_CAROL),
aMatrixUser(displayName = USER_NAME_DAVID),
aMatrixUser(displayName = USER_NAME_CHARLIE),
aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrlC"),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member still in call, but it is not the creator
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "aliceURl"),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member that is the call creator
RtcNotificationState.Active(
listOf(
aMatrixUser(
id = "@senderId:domain",
displayName = "Sender"
),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
).forEach { (state, isDm) ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
ActiveCallTimelineItemView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
onJoinCallClick = {},
)
}
}
}
}
@@ -52,6 +52,7 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
state: RtcNotificationState.Tombstoned,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
@@ -83,7 +84,7 @@ internal fun TimelineItemCallNotifyView(
Text(
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
text = stringResource(getTextRes(timelineRoomInfo, state)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
@@ -114,13 +115,13 @@ internal fun TimelineItemCallNotifyView(
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
state: RtcNotificationState.Tombstoned
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
when (state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
if (state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
else -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
@@ -166,6 +167,7 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
@@ -183,6 +183,7 @@ private fun TimelineItemGroupedEventsRowContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = {},
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
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.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
@@ -70,6 +71,7 @@ internal fun TimelineItemRow(
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
@@ -121,14 +123,27 @@ internal fun TimelineItemRow(
)
}
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
when (timelineItem.content.state) {
is RtcNotificationState.Active -> ActiveCallTimelineItemView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
)
is RtcNotificationState.Started, is RtcNotificationState.Declined ->
TimelineItemCallNotifyView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
}
}
else -> {
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
@@ -21,6 +21,8 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@@ -39,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
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.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@@ -58,13 +61,14 @@ class TimelineItemContentFactory(
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
suspend fun create(eventTimelineItem: EventTimelineItem, roomMembers: List<RoomMember>): TimelineItemEventContent {
return create(
itemContent = eventTimelineItem.content,
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
sender = eventTimelineItem.sender,
senderProfile = eventTimelineItem.senderProfile,
roomMembers = roomMembers,
)
}
@@ -74,6 +78,7 @@ class TimelineItemContentFactory(
isEditable: Boolean,
sender: UserId,
senderProfile: ProfileDetails,
roomMembers: List<RoomMember> = emptyList(),
): TimelineItemEventContent {
val isOutgoing = sessionId == sender
return when (itemContent) {
@@ -106,10 +111,23 @@ class TimelineItemContentFactory(
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent(
callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
state = if (itemContent.activeMembers.isNotEmpty()) {
RtcNotificationState.Active(
joinedMembers = itemContent.activeMembers.map { active ->
roomMembers.find {
it.userId == active
}?.toMatrixUser() ?: MatrixUser(active)
},
isJoined = itemContent.isJoined,
callStartTsMillis = itemContent.callStartTsMillis,
callIntent = itemContent.callIntent
)
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
}
)
is UnknownContent -> TimelineItemUnknownContent
@@ -108,7 +108,7 @@ class TimelineItemEventFactory(
senderId = currentSender,
senderProfile = senderProfile,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
content = contentFactory.create(currentTimelineItem.event, roomMembers),
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
@@ -10,13 +10,27 @@ 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
import io.element.android.libraries.matrix.api.user.MatrixUser
// 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
/** Call is currently active **/
data class Active(
val joinedMembers: List<MatrixUser>,
val isJoined: Boolean,
val callStartTsMillis: Long,
val callIntent: CallIntent,
) : RtcNotificationState
object Started : RtcNotificationState
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState, Tombstoned
object Started : RtcNotificationState, Tombstoned
/**
* Tag for Tombstoned/Past calls.
*/
interface Tombstoned
}
class TimelineItemRtcNotificationContent(
@@ -65,7 +65,8 @@ class DefaultMessageSummaryFormatter(
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
RtcNotificationState.Started, is RtcNotificationState.Active
-> context.getString(CommonStrings.common_call_started)
}
}
// Truncate the message to a safe length to avoid crashes in Compose
@@ -79,5 +79,6 @@ enum class AvatarSize(val dp: Dp) {
LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp),
AccountItem(32.dp),
LocationPin(32.dp)
LocationPin(32.dp),
ActiveCallItem(20.dp)
}
@@ -120,7 +120,10 @@ data object LegacyCallInviteContent : EventContent
data class CallNotifyContent(
val callIntent: CallIntent,
val declinedBy: List<UserId>
val declinedBy: List<UserId>,
val activeMembers: List<UserId>,
val isJoined: Boolean,
val callStartTsMillis: Long,
) : EventContent
data object UnknownContent : EventContent
@@ -154,7 +154,10 @@ class TimelineEventContentMapper(
} else {
CallIntent.VIDEO
},
declinedBy = it.declinedBy.map(::UserId)
declinedBy = it.declinedBy.map(::UserId),
activeMembers = it.activeMembers.map(::UserId),
callStartTsMillis = it.callStartTsMillis?.toLong() ?: 0,
isJoined = it.isJoined
)
}
}
@@ -214,6 +214,7 @@
<string name="common_blocked_users">"Blocked users"</string>
<string name="common_bubbles">"Bubbles"</string>
<string name="common_call_declined">"Call declined"</string>
<string name="common_call_in_progress">"Call in progress"</string>
<string name="common_call_started">"Call started"</string>
<string name="common_call_you_declined">"You declined a call"</string>
<string name="common_chat_backup">"Chat backup"</string>
@@ -258,6 +259,7 @@ Reason: %1$s."</string>
<string name="common_forward_message">"Forward message"</string>
<string name="common_frequently_used">"Frequently used"</string>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Group call in progress"</string>
<string name="common_image">"Image"</string>
<string name="common_in_reply_to">"In reply to %1$s"</string>
<string name="common_install_apk_android">"Install APK"</string>
@@ -400,6 +402,7 @@ Reason: %1$s."</string>
<string name="common_unmute">"Unmute"</string>
<string name="common_unsupported_call">"Unsupported call"</string>
<string name="common_unsupported_event">"Unsupported event"</string>
<string name="common_user_started_a_call">"%1$s started a call"</string>
<string name="common_username">"Username"</string>
<string name="common_verification_cancelled">"Verification cancelled"</string>
<string name="common_verification_complete">"Verification complete"</string>
@@ -534,6 +537,10 @@ Are you sure you want to continue?"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_static_location_sheet_timestamp_description">"Shared %1$s"</string>
<string name="screen_static_location_sheet_title">"On the map"</string>
<plurals name="screen_timeline_active_call_extra_joined_count">
<item quantity="one">"+ %1$d joined"</item>
<item quantity="other">"+ %1$d joined"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$s’s verified digital identity was reset."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Message not sent because you have not verified one or more of your devices."</string>