Merge pull request #3815 from element-hq/feature/bma/hideJoinCallButton
Hide join call button when the user is already in the call
This commit is contained in:
commit
6affd6f508
46 changed files with 742 additions and 157 deletions
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* 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.call.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value for the local current call.
|
||||||
|
*/
|
||||||
|
sealed interface CurrentCall {
|
||||||
|
data object None : CurrentCall
|
||||||
|
|
||||||
|
data class RoomCall(
|
||||||
|
val roomId: RoomId,
|
||||||
|
) : CurrentCall
|
||||||
|
|
||||||
|
data class ExternalUrl(
|
||||||
|
val url: String,
|
||||||
|
) : CurrentCall
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* 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.call.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface CurrentCallService {
|
||||||
|
/**
|
||||||
|
* The current call state flow, which will be updated when the active call changes.
|
||||||
|
* This value reflect the local state of the call. It is not updated if the user answers
|
||||||
|
* a call from another session.
|
||||||
|
*/
|
||||||
|
val currentCall: StateFlow<CurrentCall>
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.appconfig.ElementCallConfig
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
import io.element.android.features.call.api.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.api.CurrentCall
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
@ -82,6 +83,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||||
private val notificationManagerCompat: NotificationManagerCompat,
|
private val notificationManagerCompat: NotificationManagerCompat,
|
||||||
private val matrixClientProvider: MatrixClientProvider,
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
|
private val defaultCurrentCallService: DefaultCurrentCallService,
|
||||||
) : ActiveCallManager {
|
) : ActiveCallManager {
|
||||||
private var timedOutCallJob: Job? = null
|
private var timedOutCallJob: Job? = null
|
||||||
|
|
||||||
|
|
@ -89,6 +91,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeRingingCall()
|
observeRingingCall()
|
||||||
|
observeCurrentCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||||
|
|
@ -209,6 +212,28 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeCurrentCall() {
|
||||||
|
activeCall
|
||||||
|
.onEach { value ->
|
||||||
|
if (value == null) {
|
||||||
|
defaultCurrentCallService.onCallEnded()
|
||||||
|
} else {
|
||||||
|
when (value.callState) {
|
||||||
|
is CallState.Ringing -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
is CallState.InCall -> {
|
||||||
|
when (val callType = value.callType) {
|
||||||
|
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
|
||||||
|
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* 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.call.impl.utils
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.api.CurrentCallService
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultCurrentCallService @Inject constructor() : CurrentCallService {
|
||||||
|
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
|
||||||
|
|
||||||
|
fun onCallStarted(call: CurrentCall) {
|
||||||
|
currentCall.value = call
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCallEnded() {
|
||||||
|
currentCall.value = CurrentCall.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import io.element.android.features.call.impl.notifications.RingingCallNotificati
|
||||||
import io.element.android.features.call.impl.utils.ActiveCall
|
import io.element.android.features.call.impl.utils.ActiveCall
|
||||||
import io.element.android.features.call.impl.utils.CallState
|
import io.element.android.features.call.impl.utils.CallState
|
||||||
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
|
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
|
||||||
|
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
|
||||||
import io.element.android.features.call.test.aCallNotificationData
|
import io.element.android.features.call.test.aCallNotificationData
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -299,5 +300,6 @@ class DefaultActiveCallManagerTest {
|
||||||
),
|
),
|
||||||
notificationManagerCompat = notificationManagerCompat,
|
notificationManagerCompat = notificationManagerCompat,
|
||||||
matrixClientProvider = matrixClientProvider,
|
matrixClientProvider = matrixClientProvider,
|
||||||
|
defaultCurrentCallService = DefaultCurrentCallService(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* 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.call.test
|
||||||
|
|
||||||
|
import io.element.android.features.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.api.CurrentCallService
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakeCurrentCallService(
|
||||||
|
override val currentCall: MutableStateFlow<CurrentCall> = MutableStateFlow(CurrentCall.None),
|
||||||
|
) : CurrentCallService
|
||||||
|
|
@ -29,6 +29,7 @@ dependencies {
|
||||||
implementation(projects.features.call.api)
|
implementation(projects.features.call.api)
|
||||||
implementation(projects.features.location.api)
|
implementation(projects.features.location.api)
|
||||||
implementation(projects.features.poll.api)
|
implementation(projects.features.poll.api)
|
||||||
|
implementation(projects.features.roomcall.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
|
|
||||||
|
|
@ -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.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
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.androidutils.clipboard.ClipboardHelper
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.api.timeline.item.event.EventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
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.model.getAvatarData
|
||||||
import io.element.android.libraries.matrix.ui.room.canCall
|
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||||
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
|
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
|
||||||
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
|
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
|
||||||
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
|
@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||||
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
||||||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||||
|
val roomCallState = roomCallStatePresenter.present()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
|
|
||||||
|
|
@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Remove the unread flag on entering but don't send read receipts
|
// Remove the unread flag on entering but don't send read receipts
|
||||||
// as those will be handled by the timeline.
|
// 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(
|
return MessagesState(
|
||||||
roomId = room.roomId,
|
roomId = room.roomId,
|
||||||
roomName = roomName,
|
roomName = roomName,
|
||||||
|
|
@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
appName = buildMeta.applicationName,
|
appName = buildMeta.applicationName,
|
||||||
callState = callState,
|
roomCallState = roomCallState,
|
||||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
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.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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
|
@ -46,14 +47,8 @@ data class MessagesState(
|
||||||
val showReinvitePrompt: Boolean,
|
val showReinvitePrompt: Boolean,
|
||||||
val enableTextFormatting: Boolean,
|
val enableTextFormatting: Boolean,
|
||||||
val enableVoiceMessages: Boolean,
|
val enableVoiceMessages: Boolean,
|
||||||
val callState: RoomCallState,
|
val roomCallState: RoomCallState,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val pinnedMessagesBannerState: PinnedMessagesBannerState,
|
val pinnedMessagesBannerState: PinnedMessagesBannerState,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class RoomCallState {
|
|
||||||
ENABLED,
|
|
||||||
ONGOING,
|
|
||||||
DISABLED
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.VoiceMessageComposerState
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
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.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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -70,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aMessagesState(
|
aMessagesState(
|
||||||
callState = RoomCallState.ONGOING,
|
roomCallState = anOngoingCallState(),
|
||||||
),
|
),
|
||||||
aMessagesState(
|
aMessagesState(
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
|
|
@ -80,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aMessagesState(
|
aMessagesState(
|
||||||
callState = RoomCallState.DISABLED,
|
roomCallState = aStandByCallState(canStartCall = false),
|
||||||
),
|
),
|
||||||
aMessagesState(
|
aMessagesState(
|
||||||
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
|
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
|
||||||
|
|
@ -115,7 +118,7 @@ fun aMessagesState(
|
||||||
hasNetworkConnection: Boolean = true,
|
hasNetworkConnection: Boolean = true,
|
||||||
showReinvitePrompt: Boolean = false,
|
showReinvitePrompt: Boolean = false,
|
||||||
enableVoiceMessages: Boolean = true,
|
enableVoiceMessages: Boolean = true,
|
||||||
callState: RoomCallState = RoomCallState.ENABLED,
|
roomCallState: RoomCallState = aStandByCallState(),
|
||||||
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
|
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
|
||||||
eventSink: (MessagesEvents) -> Unit = {},
|
eventSink: (MessagesEvents) -> Unit = {},
|
||||||
) = MessagesState(
|
) = MessagesState(
|
||||||
|
|
@ -139,7 +142,7 @@ fun aMessagesState(
|
||||||
showReinvitePrompt = showReinvitePrompt,
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
callState = callState,
|
roomCallState = roomCallState,
|
||||||
appName = "Element",
|
appName = "Element",
|
||||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
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.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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
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.CustomReactionBottomSheet
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
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.VoiceMessagePermissionRationaleDialog
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
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.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
|
@ -190,7 +188,7 @@ fun MessagesView(
|
||||||
roomName = state.roomName.dataOrNull(),
|
roomName = state.roomName.dataOrNull(),
|
||||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||||
heroes = state.heroes,
|
heroes = state.heroes,
|
||||||
callState = state.callState,
|
roomCallState = state.roomCallState,
|
||||||
onBackClick = {
|
onBackClick = {
|
||||||
// Since the textfield is now based on an Android view, this is no longer done automatically.
|
// 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.
|
// We need to hide the keyboard when navigating out of this screen.
|
||||||
|
|
@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
|
||||||
roomName: String?,
|
roomName: String?,
|
||||||
roomAvatar: AvatarData?,
|
roomAvatar: AvatarData?,
|
||||||
heroes: ImmutableList<AvatarData>,
|
heroes: ImmutableList<AvatarData>,
|
||||||
callState: RoomCallState,
|
roomCallState: RoomCallState,
|
||||||
onRoomDetailsClick: () -> Unit,
|
onRoomDetailsClick: () -> Unit,
|
||||||
onJoinCallClick: () -> Unit,
|
onJoinCallClick: () -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
|
@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
CallMenuItem(
|
CallMenuItem(
|
||||||
isCallOngoing = callState == RoomCallState.ONGOING,
|
roomCallState = roomCallState,
|
||||||
onClick = onJoinCallClick,
|
onJoinCallClick = onJoinCallClick,
|
||||||
enabled = callState != RoomCallState.DISABLED
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
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
|
@Composable
|
||||||
private fun RoomAvatarAndNameRow(
|
private fun RoomAvatarAndNameRow(
|
||||||
roomName: String,
|
roomName: String,
|
||||||
|
|
|
||||||
|
|
@ -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.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
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.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
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
|
// We don't need to compute those values
|
||||||
userHasPermissionToSendMessage = false,
|
userHasPermissionToSendMessage = false,
|
||||||
userHasPermissionToSendReaction = 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
|
// don't compute this value or the pin icon will be shown
|
||||||
pinnedEventIds = emptyList(),
|
pinnedEventIds = emptyList(),
|
||||||
typingNotificationState = TypingNotificationState(
|
typingNotificationState = TypingNotificationState(
|
||||||
|
|
|
||||||
|
|
@ -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.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||||
import io.element.android.features.poll.api.actions.EndPollAction
|
import io.element.android.features.poll.api.actions.EndPollAction
|
||||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
|
|
@ -73,6 +73,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
||||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||||
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val typingNotificationState = typingNotificationPresenter.present()
|
val typingNotificationState = typingNotificationPresenter.present()
|
||||||
val timelineRoomInfo by remember(typingNotificationState) {
|
val roomCallState = roomCallStatePresenter.present()
|
||||||
|
val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
TimelineRoomInfo(
|
TimelineRoomInfo(
|
||||||
name = room.displayName,
|
name = room.displayName,
|
||||||
isDm = room.isDm,
|
isDm = room.isDm,
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||||
isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
|
roomCallState = roomCallState,
|
||||||
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
|
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
|
||||||
typingNotificationState = typingNotificationState,
|
typingNotificationState = typingNotificationState,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||||
|
|
@ -73,7 +74,7 @@ data class TimelineRoomInfo(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val userHasPermissionToSendMessage: Boolean,
|
val userHasPermissionToSendMessage: Boolean,
|
||||||
val userHasPermissionToSendReaction: Boolean,
|
val userHasPermissionToSendReaction: Boolean,
|
||||||
val isCallOngoing: Boolean,
|
val roomCallState: RoomCallState,
|
||||||
val pinnedEventIds: List<EventId>,
|
val pinnedEventIds: List<EventId>,
|
||||||
val typingNotificationState: TypingNotificationState,
|
val typingNotificationState: TypingNotificationState,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
|
||||||
name = name,
|
name = name,
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
userHasPermissionToSendReaction = true,
|
userHasPermissionToSendReaction = true,
|
||||||
isCallOngoing = false,
|
roomCallState = aStandByCallState(),
|
||||||
pinnedEventIds = pinnedEventIds,
|
pinnedEventIds = pinnedEventIds,
|
||||||
typingNotificationState = typingNotificationState,
|
typingNotificationState = typingNotificationState,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.isUserLocallyInTheCall) {
|
||||||
|
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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
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.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.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemCallNotifyView(
|
internal fun TimelineItemCallNotifyView(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
isCallOngoing: Boolean,
|
roomCallState: RoomCallState,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
onJoinCallClick: () -> Unit,
|
onJoinCallClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCallOngoing) {
|
if (roomCallState is RoomCallState.OnGoing) {
|
||||||
JoinCallMenuItem(onJoinCallClick)
|
CallMenuItem(
|
||||||
|
roomCallState = roomCallState,
|
||||||
|
onJoinCallClick = onJoinCallClick,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = event.sentTime,
|
text = event.sentTime,
|
||||||
|
|
@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
|
||||||
internal fun TimelineItemCallNotifyViewPreview() {
|
internal fun TimelineItemCallNotifyViewPreview() {
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
TimelineItemCallNotifyView(
|
RoomCallStateProvider().values.forEach { roomCallState ->
|
||||||
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
TimelineItemCallNotifyView(
|
||||||
isCallOngoing = true,
|
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
||||||
onLongClick = {},
|
roomCallState = roomCallState,
|
||||||
onJoinCallClick = {},
|
onLongClick = {},
|
||||||
)
|
onJoinCallClick = {},
|
||||||
TimelineItemCallNotifyView(
|
)
|
||||||
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
}
|
||||||
isCallOngoing = false,
|
|
||||||
onLongClick = {},
|
|
||||||
onJoinCallClick = {},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ internal fun TimelineItemRow(
|
||||||
TimelineItemCallNotifyView(
|
TimelineItemCallNotifyView(
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
isCallOngoing = timelineRoomInfo.isCallOngoing,
|
roomCallState = timelineRoomInfo.roomCallState,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onJoinCallClick = onJoinCallClick,
|
onJoinCallClick = onJoinCallClick,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.networkmonitor.test.FakeNetworkMonitor
|
||||||
import io.element.android.features.poll.api.actions.EndPollAction
|
import io.element.android.features.poll.api.actions.EndPollAction
|
||||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
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.androidutils.clipboard.FakeClipboardHelper
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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
|
@Test
|
||||||
fun `present - handle toggling a reaction`() = runTest {
|
fun `present - handle toggling a reaction`() = runTest {
|
||||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||||
|
|
@ -1030,6 +1010,7 @@ class MessagesPresenterTest {
|
||||||
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
||||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||||
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.poll.api.actions.EndPollAction
|
||||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||||
|
import io.element.android.features.roomcall.api.aStandByCallState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
|
@ -685,5 +686,6 @@ internal fun TestScope.createTimelinePresenter(
|
||||||
timelineController = TimelineController(room),
|
timelineController = TimelineController(room),
|
||||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||||
typingNotificationPresenter = { aTypingNotificationState() },
|
typingNotificationPresenter = { aTypingNotificationState() },
|
||||||
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
features/roomcall/api/build.gradle.kts
Normal file
20
features/roomcall/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
val isUserLocallyInTheCall: Boolean,
|
||||||
|
) : RoomCallState
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RoomCallState.hasPermissionToJoin() = when (this) {
|
||||||
|
is StandBy -> canStartCall
|
||||||
|
is OnGoing -> canJoinCall
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
isUserLocallyInTheCall: Boolean = isUserInTheCall,
|
||||||
|
) = RoomCallState.OnGoing(
|
||||||
|
canJoinCall = canJoinCall,
|
||||||
|
isUserInTheCall = isUserInTheCall,
|
||||||
|
isUserLocallyInTheCall = isUserLocallyInTheCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aStandByCallState(
|
||||||
|
canStartCall: Boolean = true,
|
||||||
|
) = RoomCallState.StandBy(
|
||||||
|
canStartCall = canStartCall,
|
||||||
|
)
|
||||||
38
features/roomcall/impl/build.gradle.kts
Normal file
38
features/roomcall/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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.features.call.api)
|
||||||
|
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.features.call.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
* 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.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.api.CurrentCallService
|
||||||
|
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,
|
||||||
|
private val currentCallService: CurrentCallService,
|
||||||
|
) : 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 currentCall by currentCallService.currentCall.collectAsState()
|
||||||
|
val isUserLocallyInTheCall by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
(currentCall as? CurrentCall.RoomCall)?.roomId == room.roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val callState = when {
|
||||||
|
roomInfo?.hasRoomCall == true -> RoomCallState.OnGoing(
|
||||||
|
canJoinCall = canJoinCall,
|
||||||
|
isUserInTheCall = isUserInTheCall,
|
||||||
|
isUserLocallyInTheCall = isUserLocallyInTheCall,
|
||||||
|
)
|
||||||
|
else -> RoomCallState.StandBy(canStartCall = canJoinCall)
|
||||||
|
}
|
||||||
|
return callState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/*
|
||||||
|
* 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.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.api.CurrentCallService
|
||||||
|
import io.element.android.features.call.test.FakeCurrentCallService
|
||||||
|
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.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RoomCallStatePresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canUserJoinCallResult = { Result.success(false) },
|
||||||
|
)
|
||||||
|
val presenter = createRoomCallStatePresenter(matrixRoom = room)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(
|
||||||
|
RoomCallState.StandBy(
|
||||||
|
canStartCall = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state - user can join call`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
|
)
|
||||||
|
val presenter = createRoomCallStatePresenter(matrixRoom = room)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(
|
||||||
|
RoomCallState.StandBy(
|
||||||
|
canStartCall = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
||||||
|
}
|
||||||
|
val presenter = createRoomCallStatePresenter(matrixRoom = room)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = false,
|
||||||
|
isUserInTheCall = false,
|
||||||
|
isUserLocallyInTheCall = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - user has joined the call on another session`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
hasRoomCall = true,
|
||||||
|
activeRoomCallParticipants = listOf(sessionId),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val presenter = createRoomCallStatePresenter(matrixRoom = room)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = true,
|
||||||
|
isUserInTheCall = true,
|
||||||
|
isUserLocallyInTheCall = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - user has joined the call locally`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
hasRoomCall = true,
|
||||||
|
activeRoomCallParticipants = listOf(sessionId),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val presenter = createRoomCallStatePresenter(
|
||||||
|
matrixRoom = room,
|
||||||
|
currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = true,
|
||||||
|
isUserInTheCall = true,
|
||||||
|
isUserLocallyInTheCall = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - user leaves the call`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
hasRoomCall = true,
|
||||||
|
activeRoomCallParticipants = listOf(sessionId),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.RoomCall(room.roomId))
|
||||||
|
val currentCallService = FakeCurrentCallService(currentCall = currentCall)
|
||||||
|
val presenter = createRoomCallStatePresenter(
|
||||||
|
matrixRoom = room,
|
||||||
|
currentCallService = currentCallService
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = true,
|
||||||
|
isUserInTheCall = true,
|
||||||
|
isUserLocallyInTheCall = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
currentCall.value = CurrentCall.None
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = true,
|
||||||
|
isUserInTheCall = true,
|
||||||
|
isUserLocallyInTheCall = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
room.givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
hasRoomCall = true,
|
||||||
|
activeRoomCallParticipants = emptyList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.OnGoing(
|
||||||
|
canJoinCall = true,
|
||||||
|
isUserInTheCall = false,
|
||||||
|
isUserLocallyInTheCall = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
room.givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
hasRoomCall = false,
|
||||||
|
activeRoomCallParticipants = emptyList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
RoomCallState.StandBy(
|
||||||
|
canStartCall = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRoomCallStatePresenter(
|
||||||
|
matrixRoom: MatrixRoom,
|
||||||
|
currentCallService: CurrentCallService = FakeCurrentCallService(),
|
||||||
|
): RoomCallStatePresenter {
|
||||||
|
return RoomCallStatePresenter(
|
||||||
|
room = matrixRoom,
|
||||||
|
currentCallService = currentCallService,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ dependencies {
|
||||||
implementation(projects.services.analytics.compose)
|
implementation(projects.services.analytics.compose)
|
||||||
implementation(projects.features.poll.api)
|
implementation(projects.features.poll.api)
|
||||||
implementation(projects.features.messages.api)
|
implementation(projects.features.messages.api)
|
||||||
|
implementation(projects.features.roomcall.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
|
||||||
|
|
@ -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.LeaveRoomEvent
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
|
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.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
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.canInvite
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
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.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.getCurrentRoomMember
|
||||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||||
|
|
@ -57,6 +57,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
private val notificationSettingsService: NotificationSettingsService,
|
private val notificationSettingsService: NotificationSettingsService,
|
||||||
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
|
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
|
||||||
private val leaveRoomPresenter: Presenter<LeaveRoomState>,
|
private val leaveRoomPresenter: Presenter<LeaveRoomState>,
|
||||||
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
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 membersState by room.membersStateFlow.collectAsState()
|
||||||
val canInvite by getCanInvite(membersState)
|
val canInvite by getCanInvite(membersState)
|
||||||
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
|
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
|
||||||
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
|
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
|
||||||
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
||||||
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
|
|
||||||
val dmMember by room.getDirectRoomMember(membersState)
|
val dmMember by room.getDirectRoomMember(membersState)
|
||||||
val currentMember by room.getCurrentRoomMember(membersState)
|
val currentMember by room.getCurrentRoomMember(membersState)
|
||||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||||
val roomType by getRoomType(dmMember, currentMember)
|
val roomType by getRoomType(dmMember, currentMember)
|
||||||
|
val roomCallState = roomCallStatePresenter.present()
|
||||||
|
|
||||||
val topicState = remember(canEditTopic, roomTopic, roomType) {
|
val topicState = remember(canEditTopic, roomTopic, roomType) {
|
||||||
val topic = roomTopic
|
val topic = roomTopic
|
||||||
|
|
@ -143,7 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
||||||
canShowNotificationSettings = canShowNotificationSettings.value,
|
canShowNotificationSettings = canShowNotificationSettings.value,
|
||||||
canCall = canJoinCall,
|
roomCallState = roomCallState,
|
||||||
roomType = roomType,
|
roomType = roomType,
|
||||||
roomMemberDetailsState = roomMemberDetailsState,
|
roomMemberDetailsState = roomMemberDetailsState,
|
||||||
leaveRoomState = leaveRoomState,
|
leaveRoomState = leaveRoomState,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
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.features.userprofile.api.UserProfileState
|
||||||
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
|
||||||
|
|
@ -31,7 +32,7 @@ data class RoomDetailsState(
|
||||||
val canEdit: Boolean,
|
val canEdit: Boolean,
|
||||||
val canInvite: Boolean,
|
val canInvite: Boolean,
|
||||||
val canShowNotificationSettings: Boolean,
|
val canShowNotificationSettings: Boolean,
|
||||||
val canCall: Boolean,
|
val roomCallState: RoomCallState,
|
||||||
val leaveRoomState: LeaveRoomState,
|
val leaveRoomState: LeaveRoomState,
|
||||||
val roomNotificationSettings: RoomNotificationSettings?,
|
val roomNotificationSettings: RoomNotificationSettings?,
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ package io.element.android.features.roomdetails.impl
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
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.roomdetails.impl.members.aRoomMember
|
||||||
import io.element.android.features.userprofile.api.UserProfileState
|
import io.element.android.features.userprofile.api.UserProfileState
|
||||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
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
|
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
||||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||||
),
|
),
|
||||||
aRoomDetailsState(canCall = false, canInvite = false),
|
aRoomDetailsState(roomCallState = aStandByCallState(false), canInvite = false),
|
||||||
aRoomDetailsState(isPublic = false),
|
aRoomDetailsState(isPublic = false),
|
||||||
aRoomDetailsState(heroes = aMatrixUserList()),
|
aRoomDetailsState(heroes = aMatrixUserList()),
|
||||||
aRoomDetailsState(pinnedMessagesCount = 3),
|
aRoomDetailsState(pinnedMessagesCount = 3),
|
||||||
|
|
@ -89,7 +91,7 @@ fun aRoomDetailsState(
|
||||||
canInvite: Boolean = false,
|
canInvite: Boolean = false,
|
||||||
canEdit: Boolean = false,
|
canEdit: Boolean = false,
|
||||||
canShowNotificationSettings: Boolean = true,
|
canShowNotificationSettings: Boolean = true,
|
||||||
canCall: Boolean = true,
|
roomCallState: RoomCallState = aStandByCallState(),
|
||||||
roomType: RoomDetailsType = RoomDetailsType.Room,
|
roomType: RoomDetailsType = RoomDetailsType.Room,
|
||||||
roomMemberDetailsState: UserProfileState? = null,
|
roomMemberDetailsState: UserProfileState? = null,
|
||||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||||
|
|
@ -112,7 +114,7 @@ fun aRoomDetailsState(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
canEdit = canEdit,
|
canEdit = canEdit,
|
||||||
canShowNotificationSettings = canShowNotificationSettings,
|
canShowNotificationSettings = canShowNotificationSettings,
|
||||||
canCall = canCall,
|
roomCallState = roomCallState,
|
||||||
roomType = roomType,
|
roomType = roomType,
|
||||||
roomMemberDetailsState = roomMemberDetailsState,
|
roomMemberDetailsState = roomMemberDetailsState,
|
||||||
leaveRoomState = leaveRoomState,
|
leaveRoomState = leaveRoomState,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import im.vector.app.features.analytics.plan.Interaction
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomView
|
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.BlockUserDialogs
|
||||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
||||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
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(
|
MainActionButton(
|
||||||
title = stringResource(CommonStrings.action_call),
|
title = stringResource(CommonStrings.action_call),
|
||||||
imageVector = CompoundIcons.VideoCall(),
|
imageVector = CompoundIcons.VideoCall(),
|
||||||
|
|
|
||||||
|
|
@ -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.LeaveRoomEvent
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
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.aMatrixRoom
|
||||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
|
|
@ -98,6 +99,7 @@ class RoomDetailsPresenterTest {
|
||||||
notificationSettingsService = matrixClient.notificationSettingsService(),
|
notificationSettingsService = matrixClient.notificationSettingsService(),
|
||||||
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
|
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
|
||||||
leaveRoomPresenter = { leaveRoomState },
|
leaveRoomPresenter = { leaveRoomState },
|
||||||
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
dispatchers = dispatchers,
|
dispatchers = dispatchers,
|
||||||
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
|
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3a85dfe911ca378a94b3a8fab85efab4b0915d330e804ded3472c6e6a101a9f8
|
||||||
|
size 4016
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f135ec4c24292d06f853d0ca17b90b61a8fab144e72021862152966cd750ecf9
|
||||||
|
size 3970
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b512a152991e335a54d75d83a9190c87e09aaff34aa05d57fea3c585767cb1c5
|
||||||
|
size 5658
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3ca4e84ced7c4ce27b4ff5319387756a3990edd2a8861a3b998ec5ed8705ece7
|
||||||
|
size 5381
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||||
|
size 3642
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:75256919486ff2345c7db724caf5ee3767bbfa1d4a757c3ade9fb83802a65923
|
||||||
|
size 4056
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9e124fbad6f6dd867353937772b0911ec2ce521344a4acb9af1d8ade941ca0d5
|
||||||
|
size 3980
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5777c1bc314b886fe1b195a06c27ed8144464c466fa63c9a7fd6f00d20f8ebf0
|
||||||
|
size 5433
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:63a67d21058f9a0a9077c0a8c774ab3f5aff9dcee2dbdd197b571682296ba598
|
||||||
|
size 5314
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||||
|
size 3659
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d2246cd94deba78469805df1a8ba4aa36fafee4179a5d124eb555e7f9408442e
|
oid sha256:178eef1c52bac961c9f29bc8c4df63923c8441884b1c717c9e516b9621ff3230
|
||||||
size 18644
|
size 37944
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:029ffeafb441ebc800ba531c927cfa73259a04e06a04724310a0440238442b0e
|
oid sha256:9445d05edb98b6c3bed2a2aa3354031b7e116bf7753f9aa85b4db388751154a3
|
||||||
size 18515
|
size 38154
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue