From ddd72ec526fffa36aa9a1eec36c95beacbbf1078 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 18 Jun 2026 12:16:31 +0200 Subject: [PATCH 1/2] feat(call): Active call timeline rendering --- .../features/messages/impl/MessagesView.kt | 3 + .../pinned/list/PinnedMessagesListView.kt | 1 + .../messages/impl/timeline/TimelineView.kt | 3 + .../TimelineViewMessageShieldPreview.kt | 1 + .../components/ActiveCallTimelineItemView.kt | 373 ++++++++++++++++++ .../components/TimelineItemCallNotifyView.kt | 12 +- .../TimelineItemGroupedEventsRow.kt | 1 + .../timeline/components/TimelineItemRow.kt | 31 +- .../event/TimelineItemContentFactory.kt | 26 +- .../event/TimelineItemEventFactory.kt | 2 +- .../TimelineItemRtcNotificationContent.kt | 18 +- .../DefaultMessageSummaryFormatter.kt | 3 +- .../components/avatar/AvatarSize.kt | 3 +- .../api/timeline/item/event/EventContent.kt | 5 +- .../item/event/TimelineEventContentMapper.kt | 5 +- .../src/main/res/values/localazy.xml | 7 + 16 files changed, 470 insertions(+), 24 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b299ea42a97..781ef932fe0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index bfed13028b1..a0767ad9904 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -234,6 +234,7 @@ private fun PinnedMessagesListLoaded( onMoreReactionsClick = {}, onReadReceiptClick = {}, onSwipeToReply = {}, + onJoinCallClick = {}, eventSink = { timelineItemEvent -> when (timelineItemEvent) { is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId)) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0a306aea1c4..66a3fbaf9eb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt index f0a11081e13..5f9c3d0364c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview { onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, + onJoinCallClick = {}, forceJumpToBottomVisibility = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt new file mode 100644 index 00000000000..8a5fc925d2d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt index a7ab44db541..6c86767dd42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index b41e66ae82a..f18843241b7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -183,6 +183,7 @@ private fun TimelineItemGroupedEventsRowContent( onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = {}, onSwipeToReply = {}, eventSink = eventSink, eventContentView = eventContentView, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 0229d2220b3..442c560a94a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 3b3e5118d60..d5367c872de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -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): 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 = 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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index a36755dc092..f29f23e903c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt index 2359f196a97..7d3ec21817d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt @@ -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 { + /** Call is currently active **/ + data class Active( + val joinedMembers: List, + val isJoined: Boolean, + val callStartTsMillis: Long, + val callIntent: CallIntent, + ) : RtcNotificationState + /** Some users have declined, byMe indicates if the current user is one of them. */ - data class Declined(val byMe: Boolean) : RtcNotificationState + data class Declined(val byMe: Boolean) : RtcNotificationState, Tombstoned + + object Started : RtcNotificationState, Tombstoned - object Started : RtcNotificationState + /** + * Tag for Tombstoned/Past calls. + */ + interface Tombstoned } class TimelineItemRtcNotificationContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 210e1235956..b2f7c70ad8e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 320457cedd9..eb27780073a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -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) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index f323b316f7b..d61e2f688de 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -120,7 +120,10 @@ data object LegacyCallInviteContent : EventContent data class CallNotifyContent( val callIntent: CallIntent, - val declinedBy: List + val declinedBy: List, + val activeMembers: List, + val isJoined: Boolean, + val callStartTsMillis: Long, ) : EventContent data object UnknownContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index fa671bc5466..93f7c608d74 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -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 ) } } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 75b6ab1ee77..27e6cd40eb2 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -214,6 +214,7 @@ "Blocked users" "Bubbles" "Call declined" + "Call in progress" "Call started" "You declined a call" "Chat backup" @@ -258,6 +259,7 @@ Reason: %1$s." "Forward message" "Frequently used" "GIF" + "Group call in progress" "Image" "In reply to %1$s" "Install APK" @@ -400,6 +402,7 @@ Reason: %1$s." "Unmute" "Unsupported call" "Unsupported event" + "%1$s started a call" "Username" "Verification cancelled" "Verification complete" @@ -534,6 +537,10 @@ Are you sure you want to continue?" "Spaces" "Shared %1$s" "On the map" + + "+ %1$d joined" + "+ %1$d joined" + "Message not sent because %1$s’s verified digital identity was reset." "Message not sent because %1$s has not verified all devices." "Message not sent because you have not verified one or more of your devices." From 17bcf8c55c4c650e4f6d9f192041a3db2f03ab50 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 8 Jul 2026 17:58:06 +0200 Subject: [PATCH 2/2] Reflect new design updates --- .../components/ActiveCallTimelineItemView.kt | 31 +++++++------------ .../src/main/res/values/localazy.xml | 8 ++--- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt index 8a5fc925d2d..e034f4ad4df 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt @@ -251,18 +251,7 @@ private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState ) } } 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 - + val displayMembers = state.joinedMembers.take(3) AvatarRow( avatarDataList = displayMembers .map { user -> @@ -277,13 +266,15 @@ private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState 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, - ) - } + Text( + text = pluralStringResource( + CommonPlurals.screen_timeline_active_call_joined_count, + count = state.joinedMembers.size, + state.joinedMembers.size + ), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) } } } @@ -327,7 +318,7 @@ internal fun ActiveCallTimelineItemViewPreview() = ElementPreview( aMatrixUser(displayName = USER_NAME_CHARLIE), aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrlC"), ), - isJoined = true, + isJoined = false, callStartTsMillis = System.currentTimeMillis(), callIntent = callIntent ) to false, diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 27e6cd40eb2..daf7753e4f3 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -479,6 +479,10 @@ Are you sure you want to continue?" "Location sharing in progress" "%1$s Live Location" "%1$s Android" + + "%1$d joined" + "%1$d joined" + "Rageshake to report bug" "Screenshot" "%1$s: %2$s" @@ -537,10 +541,6 @@ Are you sure you want to continue?" "Spaces" "Shared %1$s" "On the map" - - "+ %1$d joined" - "+ %1$d joined" - "Message not sent because %1$s’s verified digital identity was reset." "Message not sent because %1$s has not verified all devices." "Message not sent because you have not verified one or more of your devices."