feat: Default to camera muted when joining ongoing voice call

This commit is contained in:
Valere 2026-04-14 14:45:35 +02:00
parent 7a9260905e
commit 684d53b90e
6 changed files with 131 additions and 3 deletions

View file

@ -60,6 +60,7 @@ import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.components.InviteSenderView import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
@ -349,6 +350,7 @@ private fun MessagePreviewAndIndicatorRow(
if (room.hasRoomCall) { if (room.hasRoomCall) {
OnGoingCallIcon( OnGoingCallIcon(
color = tint, color = tint,
isAudio = room.activeCallIntent == CallIntent.AUDIO
) )
} }
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
@ -398,10 +400,11 @@ private fun InviteNameAndIndicatorRow(
@Composable @Composable
private fun OnGoingCallIcon( private fun OnGoingCallIcon(
color: Color, color: Color,
isAudio: Boolean
) { ) {
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.VideoCallSolid(), imageVector = if (isAudio) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call), contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call),
tint = color, tint = color,
) )

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
@ -50,6 +51,11 @@ class RoomListRoomSummaryFactory(
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
activeCallIntent = when (val consensus = roomInfo.activeCallIntentConsensus) {
is CallIntentConsensus.Full -> consensus.callIntent
is CallIntentConsensus.Partial -> consensus.callIntent
CallIntentConsensus.None -> null
},
isDirect = roomInfo.isDirect, isDirect = roomInfo.isDirect,
isFavorite = roomInfo.isFavorite, isFavorite = roomInfo.isFavorite,
inviteSender = roomInfo.inviter?.toInviteSender(), inviteSender = roomInfo.inviter?.toInviteSender(),

View file

@ -13,6 +13,7 @@ import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -33,6 +34,7 @@ data class RoomListRoomSummary(
val avatarData: AvatarData, val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val activeCallIntent: CallIntent?,
val isDirect: Boolean, val isDirect: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -132,6 +133,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
listOf( listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")), aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error), aRoomListRoomSummary(latestEvent = LatestEvent.Error),
),
listOf(
aRoomListRoomSummary(
name = "Active voice call",
latestEvent = LatestEvent.Synced("No activity, call"),
hasRoomCall = true,
activeCallIntent = CallIntent.AUDIO
),
) )
).flatten() ).flatten()
} }
@ -158,6 +167,7 @@ internal fun aRoomListRoomSummary(
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" }, timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null, notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
activeCallIntent: CallIntent? = null,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDirect: Boolean = false, isDirect: Boolean = false,
isDm: Boolean = false, isDm: Boolean = false,
@ -181,6 +191,7 @@ internal fun aRoomListRoomSummary(
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
activeCallIntent = activeCallIntent,
isDirect = isDirect, isDirect = isDirect,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite, isFavorite = isFavorite,

View file

@ -20,6 +20,8 @@ import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canCall import io.element.android.libraries.matrix.api.room.powerlevels.canCall
@ -57,8 +59,7 @@ class RoomCallStatePresenter(
canJoinCall = canJoinCall, canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall, isUserInTheCall = isUserInTheCall,
isUserLocallyInTheCall = isUserLocallyInTheCall, isUserLocallyInTheCall = isUserLocallyInTheCall,
// TODO resolve intent while the call is ongoing isAudioCall = roomInfo.activeCallIntentConsensus.isAudio(),
isAudioCall = false
) )
else -> RoomCallState.StandBy( else -> RoomCallState.StandBy(
canStartCall = canJoinCall, canStartCall = canJoinCall,
@ -70,3 +71,12 @@ class RoomCallStatePresenter(
return callState return callState
} }
} }
fun CallIntentConsensus.isAudio(): Boolean {
val intent = when (this) {
is CallIntentConsensus.Full -> callIntent
is CallIntentConsensus.Partial -> callIntent
is CallIntentConsensus.None -> return false
}
return intent == CallIntent.AUDIO
}

View file

@ -14,6 +14,8 @@ import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.test.FakeCurrentCallService import io.element.android.features.call.test.FakeCurrentCallService
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
@ -188,6 +190,100 @@ class RoomCallStatePresenterTest {
} }
} }
@Test
fun `present - active call with audio Intent`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeCallIntentConsensus = CallIntentConsensus.Full(CallIntent.AUDIO),
activeRoomCallParticipants = emptyList(),
)
)
}
)
val presenter = createRoomCallStatePresenter(
joinedRoom = room,
currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isAudioCall = true,
isUserInTheCall = false,
isUserLocallyInTheCall = true,
)
)
}
}
@Test
fun `present - active call with partial audio Intent`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeCallIntentConsensus = CallIntentConsensus.Partial(CallIntent.AUDIO, 1, 4),
)
)
}
)
val presenter = createRoomCallStatePresenter(
joinedRoom = room,
currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isAudioCall = true,
isUserInTheCall = false,
isUserLocallyInTheCall = true,
)
)
}
}
@Test
fun `present - active call with no intent defaults to Audio`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeCallIntentConsensus = CallIntentConsensus.None,
)
)
}
)
val presenter = createRoomCallStatePresenter(
joinedRoom = room,
currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isAudioCall = false,
isUserInTheCall = false,
isUserLocallyInTheCall = true,
)
)
}
}
@Test @Test
fun `present - user leaves the call`() = runTest { fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(