Hide the join call button if the user is already in the call.

This is at the account level so if the user has joined the call on another device, the join button will be hidden.

Extract room call state presenter to its own module and update RoomCallState model.
Let RoomDetailsPresenter use the new RoomCallStatePresenter
This commit is contained in:
Benoit Marty
2024-11-05 17:39:39 +01:00
committed by Benoit Marty
parent 6b71994321
commit 2a35edb14a
27 changed files with 419 additions and 153 deletions

View File

@@ -29,6 +29,7 @@ dependencies {
implementation(projects.features.call.api)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
implementation(projects.features.roomcall.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)

View File

@@ -50,6 +50,7 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -75,7 +76,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline.
@@ -204,12 +204,6 @@ class MessagesPresenter @AssistedInject constructor(
}
}
val callState = when {
!canJoinCall -> RoomCallState.DISABLED
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
else -> RoomCallState.ENABLED
}
return MessagesState(
roomId = room.roomId,
roomName = roomName,
@@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
callState = callState,
roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) }
)

View File

@@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -46,14 +47,8 @@ data class MessagesState(
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val callState: RoomCallState,
val roomCallState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit
)
enum class RoomCallState {
ENABLED,
ONGOING,
DISABLED
}

View File

@@ -33,6 +33,9 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -70,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
),
),
aMessagesState(
callState = RoomCallState.ONGOING,
roomCallState = anOngoingCallState(),
),
aMessagesState(
enableVoiceMessages = true,
@@ -80,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
),
),
aMessagesState(
callState = RoomCallState.DISABLED,
roomCallState = aStandByCallState(canStartCall = false),
),
aMessagesState(
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
@@ -115,7 +118,7 @@ fun aMessagesState(
hasNetworkConnection: Boolean = true,
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
@@ -139,7 +142,7 @@ fun aMessagesState(
showReinvitePrompt = showReinvitePrompt,
enableTextFormatting = true,
enableVoiceMessages = enableVoiceMessages,
callState = callState,
roomCallState = roomCallState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink,

View File

@@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
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.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@@ -69,7 +68,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@@ -81,6 +80,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -93,8 +93,6 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -190,7 +188,7 @@ fun MessagesView(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes,
callState = state.callState,
roomCallState = state.roomCallState,
onBackClick = {
// Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard when navigating out of this screen.
@@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
heroes: ImmutableList<AvatarData>,
callState: RoomCallState,
roomCallState: RoomCallState,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onBackClick: () -> Unit,
@@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
},
actions = {
CallMenuItem(
isCallOngoing = callState == RoomCallState.ONGOING,
onClick = onJoinCallClick,
enabled = callState != RoomCallState.DISABLED
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
Spacer(Modifier.width(8.dp))
},
@@ -519,24 +516,6 @@ private fun MessagesViewTopBar(
)
}
@Composable
private fun CallMenuItem(
isCallOngoing: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
if (isCallOngoing) {
JoinCallMenuItem(onJoinCallClick = onClick)
} else {
IconButton(onClick = onClick, enabled = enabled) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
}
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,

View File

@@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -89,7 +90,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
// We don't need to compute those values
userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false,
isCallOngoing = false,
// We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown
pinnedEventIds = emptyList(),
typingNotificationState = TypingNotificationState(

View File

@@ -32,8 +32,8 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
@@ -73,6 +73,7 @@ class TimelinePresenter @AssistedInject constructor(
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
@@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
}
val typingNotificationState = typingNotificationPresenter.present()
val timelineRoomInfo by remember(typingNotificationState) {
val roomCallState = roomCallStatePresenter.present()
val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
derivedStateOf {
TimelineRoomInfo(
name = room.displayName,
isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
roomCallState = roomCallState,
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
typingNotificationState = typingNotificationState,
)

View File

@@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@@ -73,7 +74,7 @@ data class TimelineRoomInfo(
val name: String?,
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean,
val roomCallState: RoomCallState,
val pinnedEventIds: List<EventId>,
val typingNotificationState: TypingNotificationState,
)

View File

@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
@@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
isCallOngoing = false,
roomCallState = aStandByCallState(),
pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState,
)

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
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.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun CallMenuItem(
roomCallState: RoomCallState,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (roomCallState) {
is RoomCallState.StandBy -> {
StandByCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
is RoomCallState.OnGoing -> {
OnGoingCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
}
}
@Composable
private fun StandByCallMenuItem(
roomCallState: RoomCallState.StandBy,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier,
onClick = onJoinCallClick,
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
@Composable
private fun OnGoingCallMenuItem(
roomCallState: RoomCallState.OnGoing,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!roomCallState.isUserInTheCall) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = modifier.heightIn(min = 36.dp),
enabled = roomCallState.canJoinCall,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
} else {
// Else user is already in the call, hide the button.
Box(modifier)
}
}
@PreviewsDayNight
@Composable
internal fun CallMenuItemPreview(
@PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState
) = ElementPreview {
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = {}
)
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun JoinCallMenuItem(
onJoinCallClick: () -> Unit,
) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = Modifier.heightIn(min = 36.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
}

View File

@@ -31,6 +31,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event,
isCallOngoing: Boolean,
roomCallState: RoomCallState,
onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier
@@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
)
}
}
if (isCallOngoing) {
JoinCallMenuItem(onJoinCallClick)
if (roomCallState is RoomCallState.OnGoing) {
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
} else {
Text(
text = event.sentTime,
@@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
internal fun TimelineItemCallNotifyViewPreview() {
ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
isCallOngoing = true,
onLongClick = {},
onJoinCallClick = {},
)
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
isCallOngoing = false,
onLongClick = {},
onJoinCallClick = {},
)
RoomCallStateProvider().values.forEach { roomCallState ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
roomCallState = roomCallState,
onLongClick = {},
onJoinCallClick = {},
)
}
}
}
}

View File

@@ -105,7 +105,7 @@ internal fun TimelineItemRow(
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
event = timelineItem,
isCallOngoing = timelineRoomInfo.isCallOngoing,
roomCallState = timelineRoomInfo.roomCallState,
onLongClick = onLongClick,
onJoinCallClick = onJoinCallClick,
)

View File

@@ -37,6 +37,7 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -139,27 +140,6 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(false) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
}
}
@Test
fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@@ -1030,6 +1010,7 @@ class MessagesPresenterTest {
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
roomCallStatePresenter = { aStandByCallState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.roomcall.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(libs.androidx.compose.ui.tooling.preview)
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.api
import androidx.compose.runtime.Immutable
import io.element.android.features.roomcall.api.RoomCallState.OnGoing
import io.element.android.features.roomcall.api.RoomCallState.StandBy
@Immutable
sealed interface RoomCallState {
data class StandBy(
val canStartCall: Boolean,
) : RoomCallState
data class OnGoing(
val canJoinCall: Boolean,
val isUserInTheCall: Boolean,
) : RoomCallState
}
fun RoomCallState.hasPermissionToJoin() = when (this) {
is StandBy -> canStartCall
is OnGoing -> canJoinCall
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RoomCallStateProvider : PreviewParameterProvider<RoomCallState> {
override val values: Sequence<RoomCallState> = sequenceOf(
aStandByCallState(),
aStandByCallState(canStartCall = false),
anOngoingCallState(),
anOngoingCallState(canJoinCall = false),
anOngoingCallState(canJoinCall = true, isUserInTheCall = true),
)
}
fun anOngoingCallState(
canJoinCall: Boolean = true,
isUserInTheCall: Boolean = false,
) = RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
)
fun aStandByCallState(
canStartCall: Boolean = true,
) = RoomCallState.StandBy(
canStartCall = canStartCall,
)

View File

@@ -0,0 +1,36 @@
import extension.setupAnvil
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.roomcall.impl"
}
setupAnvil()
dependencies {
api(projects.features.roomcall.api)
implementation(libs.kotlinx.collections.immutable)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.canCall
import javax.inject.Inject
class RoomCallStatePresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<RoomCallState> {
@Composable
override fun present(): RoomCallState {
val roomInfo by room.roomInfoFlow.collectAsState(null)
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
val isUserInTheCall by remember {
derivedStateOf {
room.sessionId in roomInfo?.activeRoomCallParticipants.orEmpty()
}
}
val callState = when {
roomInfo?.hasRoomCall == true -> RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
)
else -> RoomCallState.StandBy(canStartCall = canJoinCall)
}
return callState
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.impl.RoomCallStatePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class)
@Module
interface RoomCallModule {
@Binds
fun bindRoomCallStatePresenter(presenter: RoomCallStatePresenter): Presenter<RoomCallState>
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomCallStatePresenterTest {
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(false) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createRoomCallStatePresenter(matrixRoom = room)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(RoomCallState.OnGoing(canJoinCall = false))
}
}
private fun createRoomCallStatePresenter(
matrixRoom: MatrixRoom
): RoomCallStatePresenter {
return RoomCallStatePresenter(
room = matrixRoom,
)
}
}

View File

@@ -49,6 +49,7 @@ dependencies {
implementation(projects.services.analytics.compose)
implementation(projects.features.poll.api)
implementation(projects.features.messages.api)
implementation(projects.features.roomcall.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
@@ -37,7 +38,6 @@ import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
@@ -57,6 +57,7 @@ class RoomDetailsPresenter @Inject constructor(
private val notificationSettingsService: NotificationSettingsService,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: Presenter<LeaveRoomState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
@@ -87,18 +88,16 @@ class RoomDetailsPresenter @Inject constructor(
}
}
val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember, currentMember)
val roomCallState = roomCallStatePresenter.present()
val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic
@@ -143,7 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value,
canCall = canJoinCall,
roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,

View File

@@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@@ -31,7 +32,7 @@ data class RoomDetailsState(
val canEdit: Boolean,
val canInvite: Boolean,
val canShowNotificationSettings: Boolean,
val canCall: Boolean,
val roomCallState: RoomCallState,
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,

View File

@@ -10,6 +10,8 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState
@@ -42,7 +44,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
),
aRoomDetailsState(canCall = false, canInvite = false),
aRoomDetailsState(roomCallState = aStandByCallState(false), canInvite = false),
aRoomDetailsState(isPublic = false),
aRoomDetailsState(heroes = aMatrixUserList()),
aRoomDetailsState(pinnedMessagesCount = 3),
@@ -89,7 +91,7 @@ fun aRoomDetailsState(
canInvite: Boolean = false,
canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true,
canCall: Boolean = true,
roomCallState: RoomCallState = aStandByCallState(),
roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState: UserProfileState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@@ -112,7 +114,7 @@ fun aRoomDetailsState(
canInvite = canInvite,
canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings,
canCall = canCall,
roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,

View File

@@ -42,6 +42,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@@ -299,7 +300,8 @@ private fun MainActionsSection(
)
}
}
if (state.canCall) {
if (state.roomCallState.hasPermissionToJoin()) {
// TODO Improve the view depending on all the cases here?
MainActionButton(
title = stringResource(CommonStrings.action_call),
imageVector = CompoundIcons.VideoCall(),

View File

@@ -17,6 +17,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
@@ -98,6 +99,7 @@ class RoomDetailsPresenterTest {
notificationSettingsService = matrixClient.notificationSettingsService(),
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter = { leaveRoomState },
roomCallStatePresenter = { aStandByCallState() },
dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService,