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 8887424dc6c..dbf433a10d3 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 @@ -294,6 +294,7 @@ fun MessagesView( onSwipeToReply = { targetEvent -> state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent)) }, + onJoinCallClick = onJoinCallClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, knockRequestsBannerView = knockRequestsBannerView, @@ -474,6 +475,7 @@ private fun MessagesViewContent( onSendLocationClick: () -> Unit, onCreatePollClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, forceJumpToBottomVisibility: Boolean, onSwipeToReply: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, @@ -531,6 +533,7 @@ private fun MessagesViewContent( onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility, nestedScrollConnection = scrollBehavior.nestedScrollConnection, floatingDateTopOffset = topBannersHeightDp, 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 b4fe504ab17..c02d497721b 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 @@ -240,6 +240,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 6c0dce8cca6..077640212c2 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 @@ -130,6 +130,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, @@ -219,6 +220,7 @@ fun TimelineView( onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, onSwipeToReply = onSwipeToReply, + onJoinCallClick = onJoinCallClick, eventSink = state.eventSink, ) } @@ -599,6 +601,7 @@ internal fun TimelineViewPreview( onSwipeToReply = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, + onJoinCallClick = {}, onMoreReactionsClick = {}, onReadReceiptClick = {}, onGalleryItemClick = { _, _ -> }, 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 32d4aded2b5..4c806416596 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 = {}, onGalleryItemClick = { _, _ -> }, 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..e034f4ad4df --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ActiveCallTimelineItemView.kt @@ -0,0 +1,364 @@ +/* + * 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 displayMembers = state.joinedMembers.take(3) + 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, + ) + 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, + ) + } + } + } +} + +@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 = false, + 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 ed927956df0..61f12c646a0 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 @@ -179,6 +179,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 6a5cbfd9dd2..2221c883f8c 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 @@ -71,6 +72,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, @@ -123,14 +125,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 4ef86000437..efbbbee01e6 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 @@ -77,7 +77,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 82acfd14b35..eb33d66b5e3 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -489,6 +489,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"