mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge pull request #7051 from element-hq/feature/valere/timeline_active_call_rendering
feat(call): Active call timeline rendering
This commit is contained in:
+3
@@ -302,6 +302,7 @@ fun MessagesView(
|
||||
onSwipeToReply = { targetEvent ->
|
||||
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||
},
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = knockRequestsBannerView,
|
||||
@@ -477,6 +478,7 @@ private fun MessagesViewContent(
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -534,6 +536,7 @@ private fun MessagesViewContent(
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = topBannersHeightDp,
|
||||
|
||||
+1
@@ -239,6 +239,7 @@ private fun PinnedMessagesListLoaded(
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onJoinCallClick = {},
|
||||
eventSink = { timelineItemEvent ->
|
||||
when (timelineItemEvent) {
|
||||
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
|
||||
|
||||
+4
@@ -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 = { _, _ -> },
|
||||
@@ -649,6 +652,7 @@ private fun TimelineViewWithReadMarker(
|
||||
onReadReceiptClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
forceJumpToReadMarkerVisibility = true,
|
||||
onJoinCallClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onJoinCallClick = {},
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* 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.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
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
|
||||
) {
|
||||
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 = stringResource(CommonStrings.action_join),
|
||||
size = ButtonSize.Small,
|
||||
onClick = { onJoinCallClick(state.callIntent == CallIntent.AUDIO) }
|
||||
)
|
||||
}
|
||||
if (state.callStartTsMillis != null) {
|
||||
CallDurationText(
|
||||
state.callStartTsMillis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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 = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
|
||||
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 = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallDurationText(
|
||||
startTsMillis: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun formatElapsed(startTsMillis: Long): String =
|
||||
(System.currentTimeMillis() - startTsMillis).coerceAtLeast(0).toHumanReadableDuration()
|
||||
|
||||
val duration by produceState(
|
||||
initialValue = formatElapsed(startTsMillis),
|
||||
startTsMillis,
|
||||
) {
|
||||
while (true) {
|
||||
delay(1.seconds)
|
||||
value = formatElapsed(startTsMillis)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = "($duration)",
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -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 = {},
|
||||
|
||||
+1
@@ -186,6 +186,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onJoinCallClick = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
|
||||
+23
-8
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
+26
-5
@@ -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,26 @@ class TimelineItemContentFactory(
|
||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
is CallNotifyContent -> TimelineItemRtcNotificationContent(
|
||||
callIntent = itemContent.callIntent,
|
||||
state = if (itemContent.declinedBy.isEmpty()) {
|
||||
RtcNotificationState.Started
|
||||
} else {
|
||||
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
|
||||
state = when {
|
||||
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 -> {
|
||||
if (itemContent.declinedBy.isEmpty()) {
|
||||
RtcNotificationState.Started
|
||||
} else {
|
||||
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
is UnknownContent -> TimelineItemUnknownContent
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+17
-3
@@ -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(
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
@@ -218,6 +218,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||
TimelineView(
|
||||
@@ -234,6 +235,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
}
|
||||
|
||||
+17
-5
@@ -43,8 +43,11 @@ class RtcNotificationContentFormatterTest : RobolectricTest() {
|
||||
fun `Should not display declined info in rooms`() {
|
||||
val result = formatter.format(
|
||||
CallNotifyContent(
|
||||
CallIntent.VIDEO,
|
||||
declinedBy = listOf(A_USER_ID_2, A_USER_ID_3)
|
||||
callIntent = CallIntent.VIDEO,
|
||||
declinedBy = listOf(A_USER_ID_2, A_USER_ID_3),
|
||||
activeMembers = emptyList(),
|
||||
isJoined = false,
|
||||
callStartTsMillis = 0
|
||||
),
|
||||
false
|
||||
)
|
||||
@@ -58,7 +61,10 @@ class RtcNotificationContentFormatterTest : RobolectricTest() {
|
||||
val result = formatter.format(
|
||||
CallNotifyContent(
|
||||
CallIntent.VIDEO,
|
||||
declinedBy = listOf(fakeMatrixClient.sessionId)
|
||||
declinedBy = listOf(fakeMatrixClient.sessionId),
|
||||
activeMembers = listOf(A_USER_ID_2),
|
||||
isJoined = false,
|
||||
callStartTsMillis = 0
|
||||
),
|
||||
true
|
||||
)
|
||||
@@ -72,7 +78,10 @@ class RtcNotificationContentFormatterTest : RobolectricTest() {
|
||||
val result = formatter.format(
|
||||
CallNotifyContent(
|
||||
CallIntent.VIDEO,
|
||||
declinedBy = listOf(A_USER_ID_2)
|
||||
declinedBy = listOf(A_USER_ID_2),
|
||||
activeMembers = listOf(fakeMatrixClient.sessionId),
|
||||
isJoined = true,
|
||||
callStartTsMillis = 0
|
||||
),
|
||||
true
|
||||
)
|
||||
@@ -86,7 +95,10 @@ class RtcNotificationContentFormatterTest : RobolectricTest() {
|
||||
val result = formatter.format(
|
||||
CallNotifyContent(
|
||||
CallIntent.AUDIO,
|
||||
declinedBy = listOf()
|
||||
declinedBy = listOf(),
|
||||
activeMembers = emptyList(),
|
||||
isJoined = false,
|
||||
callStartTsMillis = 0
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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(),
|
||||
isJoined = it.isJoined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -68,7 +68,13 @@ class EventItemFactoryTest {
|
||||
fun `create check all null cases`() {
|
||||
val factory = createEventItemFactory()
|
||||
val contents = listOf(
|
||||
CallNotifyContent(callIntent = CallIntent.VIDEO, emptyList()),
|
||||
CallNotifyContent(
|
||||
callIntent = CallIntent.VIDEO,
|
||||
declinedBy = emptyList(),
|
||||
activeMembers = emptyList(),
|
||||
isJoined = false,
|
||||
callStartTsMillis = null,
|
||||
),
|
||||
FailedToParseMessageLikeContent("", ""),
|
||||
FailedToParseStateContent("", "", ""),
|
||||
LegacyCallInviteContent,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user