diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 803e627db87..b4252d8cce3 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -202,4 +202,9 @@ class CallsModule { @Provides fun provideObserveConferenceCallingEnabledUseCase(callsScope: CallsScope) = callsScope.observeConferenceCallingEnabled + + @ViewModelScoped + @Provides + fun provideObserveInCallReactionsUseCase(callsScope: CallsScope) = + callsScope.observeInCallReactions } diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index ad72e7b2fea..2c1f3dd1692 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -28,6 +28,7 @@ import com.wire.kalium.logic.feature.asset.ObserveAssetStatusesUseCase import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetNotificationsUseCase @@ -216,4 +217,9 @@ class MessageModule { @Provides fun provideRemoveMessageDraftUseCase(messageScope: MessageScope): RemoveMessageDraftUseCase = messageScope.removeMessageDraftUseCase + + @ViewModelScoped + @Provides + fun provideSendInCallReactionUseCase(messageScope: MessageScope): SendInCallReactionUseCase = + messageScope.sendInCallReactionUseCase } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt index 807f0600b43..e5cf76085cb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.calling import android.view.View import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel @@ -29,7 +30,11 @@ import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger import com.wire.android.model.ImageAsset +import com.wire.android.ui.calling.model.InCallReaction +import com.wire.android.ui.calling.model.ReactionSender import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions +import com.wire.android.util.ExpiringMap import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.ConversationTypeForCall @@ -37,11 +42,14 @@ import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveSpeakerUseCase import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase @@ -49,6 +57,8 @@ import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase +import com.wire.kalium.logic.functional.onSuccess import com.wire.kalium.logic.util.PlatformView import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -57,14 +67,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch @Suppress("LongParameterList", "TooManyFunctions") @@ -83,6 +101,8 @@ class SharedCallingViewModel @AssistedInject constructor( private val flipToFrontCamera: FlipToFrontCameraUseCase, private val flipToBackCamera: FlipToBackCameraUseCase, private val observeSpeaker: ObserveSpeakerUseCase, + private val observeInCallReactionsUseCase: ObserveInCallReactionsUseCase, + private val sendInCallReactionUseCase: SendInCallReactionUseCase, private val callRinger: CallRinger, private val uiCallParticipantMapper: UICallParticipantMapper, private val userTypeMapper: UserTypeMapper, @@ -93,6 +113,15 @@ class SharedCallingViewModel @AssistedInject constructor( var participantsState by mutableStateOf(persistentListOf()) + private val _inCallReactions = Channel( + capacity = 300, // Max reactions to keep in queue + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val inCallReactions = _inCallReactions.receiveAsFlow().withDelayAfterFirst(InCallReactions.reactionsThrottleDelayMs) + + val recentReactions = recentInCallReactionMap() + init { viewModelScope.launch { val allCallsSharedFlow = observeEstablishedCallWithSortedParticipants(conversationId) @@ -110,6 +139,8 @@ class SharedCallingViewModel @AssistedInject constructor( launch { observeOnSpeaker(this) } + + observeInCallReactions() } } @@ -279,8 +310,48 @@ class SharedCallingViewModel @AssistedInject constructor( } } + private fun observeInCallReactions() { + observeInCallReactionsUseCase(conversationId).onEach { message -> + + val sender = participantsState.senderName(message.senderUserId)?.let { name -> + ReactionSender.Other(name) + } ?: ReactionSender.Unknown + + message.emojis.forEach { emoji -> + _inCallReactions.send(InCallReaction(emoji, sender)) + } + + if (message.emojis.isNotEmpty()) { + recentReactions.put(message.senderUserId, message.emojis.last()) + } + }.launchIn(viewModelScope) + } + + fun onReactionClick(emoji: String) { + viewModelScope.launch { + sendInCallReactionUseCase(conversationId, emoji).onSuccess { + _inCallReactions.send(InCallReaction(emoji, ReactionSender.You)) + } + } + } + + private fun recentInCallReactionMap(): MutableMap = + ExpiringMap( + scope = viewModelScope, + expiration = InCallReactions.recentReactionShowDurationMs, + delegate = mutableStateMapOf() + ) + @AssistedFactory interface Factory { fun create(conversationId: ConversationId): SharedCallingViewModel } } + +private fun List.senderName(userId: QualifiedID) = firstOrNull { it.id.value == userId.value }?.name + +private fun Flow.withDelayAfterFirst(timeMillis: Long): Flow = withIndex() + .map { (index, value) -> + if (index > 0) delay(timeMillis) + value + } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt index cc920e44ffe..3acf75cd9a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt @@ -20,10 +20,8 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.permission.rememberCameraPermissionFlow import com.wire.android.util.ui.PreviewMultipleThemes @@ -34,7 +32,6 @@ fun CameraButton( onPermissionPermanentlyDenied: () -> Unit, modifier: Modifier = Modifier, isCameraOn: Boolean = false, - size: Dp = dimensions().defaultCallingControlsSize, ) { val cameraPermissionCheck = rememberCameraPermissionFlow( onPermissionGranted = { @@ -56,7 +53,6 @@ fun CameraButton( false -> R.string.content_description_calling_turn_camera_on }, onClick = cameraPermissionCheck::launch, - size = size, modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt index c64e3cb7379..41812569a70 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt @@ -38,7 +38,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes fun HangUpButton( onHangUpButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().bigCallingControlsSize, + size: Dp = dimensions().defaultCallingControlsHeight, iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize, ) { WirePrimaryIconButton( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt new file mode 100644 index 00000000000..1a484e26b04 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.calling.controlbuttons + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import com.wire.android.R +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryIconButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun HangUpOngoingButton( + onHangUpButtonClicked: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = dimensions().defaultCallingControlsWidth, + height: Dp = dimensions().defaultCallingControlsHeight, + iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize, +) { + WirePrimaryIconButton( + iconResource = R.drawable.ic_call_reject, + contentDescription = R.string.content_description_calling_hang_up_call, + state = WireButtonState.Error, + shape = CircleShape, + minSize = DpSize(width, height), + minClickableSize = DpSize(width, height), + iconSize = iconSize, + onButtonClicked = onHangUpButtonClicked, + modifier = modifier, + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewComposableHangUpOngoingButton() = WireTheme { + HangUpOngoingButton( + modifier = Modifier + .width(MaterialTheme.wireDimensions.bigCallingControlsSize) + .height(MaterialTheme.wireDimensions.bigCallingControlsSize), + onHangUpButtonClicked = { } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt similarity index 54% rename from app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt rename to app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt index bc58e225943..0a8d3d3bb3e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt @@ -15,48 +15,49 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ - package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @Composable -fun CameraFlipButton( - onCameraFlipButtonClicked: () -> Unit, +fun InCallReactionsButton( + isSelected: Boolean, + onInCallReactionsClick: () -> Unit, modifier: Modifier = Modifier, - isOnFrontCamera: Boolean = false, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( - isSelected = !isOnFrontCamera, - iconResId = when (isOnFrontCamera) { - true -> R.drawable.ic_camera_flipped - false -> R.drawable.ic_camera_flip + isSelected = isSelected, + iconResId = when (isSelected) { + true -> R.drawable.ic_incall_reactions + false -> R.drawable.ic_incall_reactions }, - contentDescription = when (isOnFrontCamera) { - true -> R.string.content_description_calling_flip_camera_on - false -> R.string.content_description_calling_flip_camera_off + contentDescription = when (isSelected) { + true -> R.string.content_description_calling_unmute_call + false -> R.string.content_description_calling_mute_call }, - onClick = onCameraFlipButtonClicked, - size = size, - modifier = modifier, + onClick = onInCallReactionsClick, + modifier = modifier ) } @PreviewMultipleThemes @Composable -fun PreviewCameraFlipButtonOn() = WireTheme { - CameraFlipButton(isOnFrontCamera = true, onCameraFlipButtonClicked = { }) +fun PreviewInCallReactionsButton() = WireTheme { + InCallReactionsButton( + isSelected = false, + onInCallReactionsClick = { } + ) } @PreviewMultipleThemes @Composable -fun PreviewCameraFlipButtonOff() = WireTheme { - CameraFlipButton(isOnFrontCamera = false, onCameraFlipButtonClicked = { }) +fun PreviewInCallReactionsButtonSelected() = WireTheme { + InCallReactionsButton( + isSelected = true, + onInCallReactionsClick = { } + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt index 272f6da8b92..ea76130a913 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt @@ -20,9 +20,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @@ -31,7 +29,6 @@ fun MicrophoneButton( isMuted: Boolean, onMicrophoneButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( isSelected = !isMuted, @@ -44,7 +41,6 @@ fun MicrophoneButton( false -> R.string.content_description_calling_mute_call }, onClick = onMicrophoneButtonClicked, - size = size, modifier = modifier ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt index 46bf66d8f50..d399f3e43c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt @@ -20,9 +20,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @@ -31,7 +29,6 @@ fun SpeakerButton( isSpeakerOn: Boolean, onSpeakerButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( isSelected = isSpeakerOn, @@ -44,7 +41,6 @@ fun SpeakerButton( false -> R.string.content_description_calling_turn_speaker_on }, onClick = onSpeakerButtonClicked, - size = size, modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt index 3f438a43087..4208a19e0a5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt @@ -20,14 +20,13 @@ package com.wire.android.ui.calling.controlbuttons import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import com.wire.android.ui.common.button.WireButtonState -import com.wire.android.ui.common.button.WireSecondaryIconButton +import com.wire.android.ui.common.button.WirePrimaryIconButton import com.wire.android.ui.common.button.wireSecondaryButtonColors import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -39,10 +38,11 @@ fun WireCallControlButton( @StringRes contentDescription: Int, onClick: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize, + width: Dp = dimensions().defaultCallingControlsWidth, + height: Dp = dimensions().defaultCallingControlsHeight, iconSize: Dp = dimensions().defaultCallingControlsIconSize ) { - WireSecondaryIconButton( + WirePrimaryIconButton( onButtonClicked = onClick, iconResource = iconResId, shape = CircleShape, @@ -60,9 +60,9 @@ fun WireCallControlButton( }, contentDescription = contentDescription, state = if (isSelected) WireButtonState.Selected else WireButtonState.Default, - minSize = DpSize(size, size), - minClickableSize = DpSize(size, size), + minSize = DpSize(width, height), + minClickableSize = DpSize(width, height), iconSize = iconSize, - modifier = modifier.size(size), + modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt b/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt new file mode 100644 index 00000000000..0470beae5dc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt @@ -0,0 +1,29 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.model + +data class InCallReaction( + val emoji: String, + val sender: ReactionSender, +) + +sealed interface ReactionSender { + data object You : ReactionSender + data class Other(val name: String) : ReactionSender + data object Unknown : ReactionSender +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index ec577f2c221..315bbe374e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -21,6 +21,10 @@ package com.wire.android.ui.calling.ongoing import android.content.pm.PackageManager import android.view.View import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -32,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue @@ -65,14 +68,20 @@ import com.wire.android.ui.calling.CallState import com.wire.android.ui.calling.ConversationName import com.wire.android.ui.calling.SharedCallingViewModel import com.wire.android.ui.calling.controlbuttons.CameraButton -import com.wire.android.ui.calling.controlbuttons.CameraFlipButton -import com.wire.android.ui.calling.controlbuttons.HangUpButton +import com.wire.android.ui.calling.controlbuttons.HangUpOngoingButton +import com.wire.android.ui.calling.controlbuttons.InCallReactionsButton import com.wire.android.ui.calling.controlbuttons.MicrophoneButton import com.wire.android.ui.calling.controlbuttons.SpeakerButton +import com.wire.android.ui.calling.model.InCallReaction import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.fullscreen.DoubleTapToast import com.wire.android.ui.calling.ongoing.fullscreen.FullScreenTile import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.AnimatableReaction +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsPanel +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsState +import com.wire.android.ui.calling.ongoing.incallreactions.drawInCallReactions +import com.wire.android.ui.calling.ongoing.incallreactions.rememberInCallReactionsState import com.wire.android.ui.calling.ongoing.participantsview.FloatingSelfUserTile import com.wire.android.ui.calling.ongoing.participantsview.VerticalCallingPager import com.wire.android.ui.common.ConversationVerificationIcons @@ -85,11 +94,11 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.emoji.EmojiPickerBottomSheet import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.call.CallStatus @@ -100,6 +109,7 @@ import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.collectLatest import java.util.Locale @Suppress("ParameterWrapping") @@ -118,6 +128,8 @@ fun OngoingCallScreen( val permissionPermanentlyDeniedDialogState = rememberVisibilityState() + val inCallReactionsState = rememberInCallReactionsState() + val activity = LocalActivity.current val isPiPAvailableOnThisDevice = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) @@ -133,6 +145,13 @@ fun OngoingCallScreen( } } } + + LaunchedEffect(Unit) { + sharedCallingViewModel.inCallReactions.collectLatest { reaction -> + inCallReactionsState.runAnimation(reaction) + } + } + val hangUpCall = remember { { sharedCallingViewModel.hangUpCall { activity.finishAndRemoveTask() } @@ -167,6 +186,7 @@ fun OngoingCallScreen( val inPictureInPictureMode = activity.isInPictureInPictureMode OngoingCallContent( callState = sharedCallingViewModel.callState, + inCallReactionsState = inCallReactionsState, shouldShowDoubleTapToast = ongoingCallViewModel.shouldShowDoubleTapToast, toggleSpeaker = sharedCallingViewModel::toggleSpeaker, toggleMute = sharedCallingViewModel::toggleMute, @@ -181,9 +201,11 @@ fun OngoingCallScreen( selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, + onReactionClick = sharedCallingViewModel::onReactionClick, participants = sharedCallingViewModel.participantsState, inPictureInPictureMode = inPictureInPictureMode, currentUserId = ongoingCallViewModel.currentUserId, + recentReactions = sharedCallingViewModel.recentReactions, ) BackHandler { @@ -279,6 +301,7 @@ private fun HandleSendingVideoFeed( @Composable private fun OngoingCallContent( callState: CallState, + inCallReactionsState: InCallReactionsState, shouldShowDoubleTapToast: Boolean, toggleSpeaker: () -> Unit, toggleMute: () -> Unit, @@ -290,10 +313,12 @@ private fun OngoingCallContent( onCollapse: () -> Unit, hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, + onReactionClick: (String) -> Unit, requestVideoStreams: (participants: List) -> Unit, onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, + recentReactions: Map, inPictureInPictureMode: Boolean, currentUserId: UserId, ) { @@ -308,6 +333,9 @@ private fun OngoingCallContent( var shouldOpenFullScreen by remember { mutableStateOf(false) } + var showInCallReactionsPanel by remember { mutableStateOf(false) } + var showEmojiPicker by remember { mutableStateOf(false) } + WireBottomSheetScaffold( sheetDragHandle = null, topBar = if (inPictureInPictureMode) { @@ -336,19 +364,22 @@ private fun OngoingCallContent( conversationId = callState.conversationId, isMuted = callState.isMuted ?: true, isCameraOn = callState.isCameraOn, - isOnFrontCamera = callState.isOnFrontCamera, isSpeakerOn = callState.isSpeakerOn, + isShowingCallReactions = showInCallReactionsPanel, toggleSpeaker = toggleSpeaker, toggleMute = toggleMute, onHangUpCall = hangUpCall, onToggleVideo = toggleVideo, - flipCamera = flipCamera, + onCallReactionsClick = { + showInCallReactionsPanel = !showInCallReactionsPanel + }, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied ) } }, + sheetContainerColor = colorsScheme().background, ) { - BoxWithConstraints( + Column( modifier = Modifier .padding( top = it.calculateTopPadding(), @@ -356,45 +387,53 @@ private fun OngoingCallContent( ) ) { - if (participants.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), - size = dimensions().spacing32x - ) - Text( - text = stringResource(id = R.string.calling_screen_connecting_until_call_established), - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } else { - Box( - modifier = Modifier.fillMaxSize() - ) { - - // if there is only one in the call, do not allow full screen - if (participants.size == 1) { - shouldOpenFullScreen = false - } - - // if we are on full screen, and that user left the call, then we leave the full screen - if (participants.find { user -> user.id == selectedParticipantForFullScreen.userId } == null) { - shouldOpenFullScreen = false + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + + if (participants.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + size = dimensions().spacing32x + ) + Text( + text = stringResource(id = R.string.calling_screen_connecting_until_call_established), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) } - - if (shouldOpenFullScreen) { - hideDoubleTapToast() - FullScreenTile( - callState = callState, - selectedParticipant = selectedParticipantForFullScreen, - height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, - closeFullScreen = { - onSelectedParticipant(SelectedParticipant()) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .drawInCallReactions(state = inCallReactionsState) + ) { + + // if there is only one in the call, do not allow full screen + if (participants.size == 1) { + shouldOpenFullScreen = false + } + + // if we are on full screen, and that user left the call, then we leave the full screen + if (participants.find { user -> user.id == selectedParticipantForFullScreen.userId } == null) { + shouldOpenFullScreen = false + } + + if (shouldOpenFullScreen) { + hideDoubleTapToast() + FullScreenTile( + callState = callState, + selectedParticipant = selectedParticipantForFullScreen, + height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, + closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, onBackButtonClicked = { @@ -404,48 +443,84 @@ private fun OngoingCallContent( requestVideoStreams = requestVideoStreams, setVideoPreview = setVideoPreview, clearVideoPreview = clearVideoPreview, - participants = participants - ) - } else { - VerticalCallingPager( participants = participants, - isSelfUserCameraOn = callState.isCameraOn, - isSelfUserMuted = callState.isMuted ?: true, - isInPictureInPictureMode = inPictureInPictureMode, - contentHeight = this@BoxWithConstraints.maxHeight, - onSelfVideoPreviewCreated = setVideoPreview, - onSelfClearVideoPreview = clearVideoPreview, - requestVideoStreams = requestVideoStreams, - currentUserId = currentUserId, - onDoubleTap = { selectedParticipant -> - onSelectedParticipant(selectedParticipant) - shouldOpenFullScreen = !shouldOpenFullScreen - }, - ) - DoubleTapToast( - modifier = Modifier.align(Alignment.TopCenter), - enabled = shouldShowDoubleTapToast, - text = stringResource(id = R.string.calling_ongoing_double_tap_for_full_screen), - onTap = hideDoubleTapToast - ) - } - if (BuildConfig.PICTURE_IN_PICTURE_ENABLED && participants.size > 1) { - val selfUser = - participants.first { participant -> - // API returns only id.value, without domain, till this get changed compare only id.value - participant.id.equalsIgnoringBlankDomain(currentUserId) - } - FloatingSelfUserTile( - modifier = Modifier.align(Alignment.TopEnd), - contentHeight = this@BoxWithConstraints.maxHeight, - contentWidth = this@BoxWithConstraints.maxWidth, - participant = selfUser, - onSelfUserVideoPreviewCreated = setVideoPreview, - onClearSelfUserVideoPreview = clearVideoPreview - ) + isOnFrontCamera = callState.isOnFrontCamera, + flipCamera = flipCamera, + ) + } else { + VerticalCallingPager( + participants = participants, + isSelfUserCameraOn = callState.isCameraOn, + isSelfUserMuted = callState.isMuted ?: true, + isInPictureInPictureMode = inPictureInPictureMode, + isOnFrontCamera = callState.isOnFrontCamera, + contentHeight = this@BoxWithConstraints.maxHeight, + onSelfVideoPreviewCreated = setVideoPreview, + onSelfClearVideoPreview = clearVideoPreview, + requestVideoStreams = requestVideoStreams, + currentUserId = currentUserId, + recentReactions = recentReactions, + onDoubleTap = { selectedParticipant -> + onSelectedParticipant(selectedParticipant) + shouldOpenFullScreen = !shouldOpenFullScreen + }, + flipCamera = flipCamera, + ) + DoubleTapToast( + modifier = Modifier.align(Alignment.TopCenter), + enabled = shouldShowDoubleTapToast, + text = stringResource(id = R.string.calling_ongoing_double_tap_for_full_screen), + onTap = hideDoubleTapToast + ) + } + if (BuildConfig.PICTURE_IN_PICTURE_ENABLED && participants.size > 1) { + val selfUser = + participants.first { participant -> + // API returns only id.value, without domain, till this get changed compare only id.value + participant.id.equalsIgnoringBlankDomain(currentUserId) + } + FloatingSelfUserTile( + modifier = Modifier.align(Alignment.TopEnd), + contentHeight = this@BoxWithConstraints.maxHeight, + contentWidth = this@BoxWithConstraints.maxWidth, + participant = selfUser, + isOnFrontCamera = callState.isOnFrontCamera, + onSelfUserVideoPreviewCreated = setVideoPreview, + onClearSelfUserVideoPreview = clearVideoPreview, + flipCamera = flipCamera, + ) + } } } } + + AnimatedContent( + targetState = showInCallReactionsPanel, + transitionSpec = { + val enter = slideInVertically(initialOffsetY = { it }) + val exit = slideOutVertically(targetOffsetY = { it }) + enter.togetherWith(exit) + }, + label = "InCallReactions" + ) { show -> + if (show) { + InCallReactionsPanel( + onReactionClick = onReactionClick, + onMoreClick = { showEmojiPicker = true } + ) + } + } + + EmojiPickerBottomSheet( + isVisible = showEmojiPicker, + onEmojiSelected = { + showEmojiPicker = false + onReactionClick(it) + }, + onDismiss = { + showEmojiPicker = false + }, + ) } } } @@ -508,12 +583,12 @@ private fun CallingControls( isMuted: Boolean, isCameraOn: Boolean, isSpeakerOn: Boolean, - isOnFrontCamera: Boolean, + isShowingCallReactions: Boolean, toggleSpeaker: () -> Unit, toggleMute: () -> Unit, onHangUpCall: () -> Unit, onToggleVideo: () -> Unit, - flipCamera: () -> Unit, + onCallReactionsClick: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit ) { Column( @@ -527,7 +602,10 @@ private fun CallingControls( .fillMaxWidth() .height(dimensions().spacing56x) ) { - MicrophoneButton(isMuted = isMuted, onMicrophoneButtonClicked = toggleMute) + MicrophoneButton( + isMuted = isMuted, + onMicrophoneButtonClicked = toggleMute + ) CameraButton( isCameraOn = isCameraOn, onPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, @@ -539,15 +617,12 @@ private fun CallingControls( onSpeakerButtonClicked = toggleSpeaker ) - if (isCameraOn) { - CameraFlipButton( - isOnFrontCamera = isOnFrontCamera, - onCameraFlipButtonClicked = flipCamera - ) - } + InCallReactionsButton( + isSelected = isShowingCallReactions, + onInCallReactionsClick = onCallReactionsClick + ) - HangUpButton( - modifier = Modifier.size(MaterialTheme.wireDimensions.defaultCallingControlsSize), + HangUpOngoingButton( onHangUpButtonClicked = onHangUpCall ) } @@ -556,6 +631,7 @@ private fun CallingControls( } } +@Suppress("EmptyFunctionBlock") @Composable fun PreviewOngoingCallContent(participants: PersistentList) { OngoingCallContent( @@ -571,6 +647,10 @@ fun PreviewOngoingCallContent(participants: PersistentList) { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, ), + inCallReactionsState = object : InCallReactionsState { + override fun runAnimation(inCallReaction: InCallReaction) {} + override fun getReactions(): List = emptyList() + }, shouldShowDoubleTapToast = false, toggleSpeaker = {}, toggleMute = {}, @@ -582,12 +662,14 @@ fun PreviewOngoingCallContent(participants: PersistentList) { onCollapse = {}, hideDoubleTapToast = {}, onCameraPermissionPermanentlyDenied = {}, + onReactionClick = {}, requestVideoStreams = {}, participants = participants, inPictureInPictureMode = false, currentUserId = UserId("userId", "domain"), onSelectedParticipant = {}, selectedParticipantForFullScreen = SelectedParticipant(), + recentReactions = emptyMap(), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 84c6e1a57d1..a70325a0cd7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel(assistedFactory = OngoingCallViewModel.Factory::class) class OngoingCallViewModel @AssistedInject constructor( @Assisted diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index cee64f83032..76a30816d96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -62,6 +62,8 @@ fun FullScreenTile( setVideoPreview: (View) -> Unit, requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, ) { @@ -101,7 +103,9 @@ fun FullScreenTile( shouldFillOthersVideoPreview = false, isZoomingEnabled = true, onSelfUserVideoPreviewCreated = setVideoPreview, - onClearSelfUserVideoPreview = clearVideoPreview + onClearSelfUserVideoPreview = clearVideoPreview, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) LaunchedEffect(Unit) { delay(200) @@ -147,5 +151,7 @@ fun PreviewFullScreenTile() = WireTheme { requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, + isOnFrontCamera = false, + flipCamera = {}, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt new file mode 100644 index 00000000000..336a5d39397 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt @@ -0,0 +1,52 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.incallreactions + +@Suppress("MagicNumber") +object InCallReactions { + + /** + * Default in call reaction emojis + */ + val defaultReactions = listOf("👍", "🎉", "❤️", "😂", "😮", "👏", "🤔", "😢", "👎") + + /** + * Next reaction click is disabled until delay expires + */ + const val reactionDelayMs = 3000L + + /** + * Total duration for reaction animation + */ + const val animationDurationMs = 3000 + + /** + * Duration for reaction fade out animation + */ + const val fadeOutAnimationDuarationMs = 500 + + /** + * Delay between displaying reactions on screen + */ + const val reactionsThrottleDelayMs: Long = 200 + + /** + * Duration for showing recent reaction next to user image + */ + const val recentReactionShowDurationMs: Long = 6000 +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt new file mode 100644 index 00000000000..eaafe6c35fd --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt @@ -0,0 +1,137 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.incallreactions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toSize +import com.wire.android.R +import com.wire.android.ui.calling.model.ReactionSender +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography +import kotlin.math.max + +@Composable +fun Modifier.drawInCallReactions( + state: InCallReactionsState, + labelTextColor: Color = Color.White, + labelColor: Color = Color.Black, + emojiBackgroundColor: Color = colorsScheme().emojiBackgroundColor, + emojiBackgroundSize: Dp = dimensions().inCallReactionButtonSize, + emojiTextStyle: TextStyle = typography().inCallReactionEmoji, + labelTextStyle: TextStyle = typography().label01, +): Modifier { + + val textMeasurer = rememberTextMeasurer() + + val emojiBackgroundSizePx = with(LocalDensity.current) { emojiBackgroundSize.toPx() } + val labelTopMarginPx = with(LocalDensity.current) { dimensions().spacing12x.toPx() } + val labelTextPaddingPx = with(LocalDensity.current) { dimensions().spacing4x.toPx() } + val reactionSenderSelf = stringResource(R.string.reaction_sender_self) + + return this then Modifier.drawWithContent { + + drawContent() + + clipRect(left = 0f, top = 0f, right = size.width, bottom = size.height) { + + state.getReactions().forEach { reaction -> + + val senderText = when (reaction.inCallReaction.sender) { + is ReactionSender.You -> reactionSenderSelf + is ReactionSender.Other -> reaction.inCallReaction.sender.name + is ReactionSender.Unknown -> "" + } + + val emojiLayoutResult = textMeasurer.measure(reaction.inCallReaction.emoji, emojiTextStyle) + val labelLayoutResult = textMeasurer.measure(senderText, labelTextStyle) + + val emojiSize = emojiLayoutResult.size.toSize() + val labelSize = Size( + width = labelLayoutResult.size.width + labelTextPaddingPx * 2, + height = labelLayoutResult.size.height + labelTextPaddingPx * 2 + ) + + val offsetVertical = size.height - size.height * reaction.verticalOffset.value + val offsetHorizontal = (size.width - max(emojiSize.width, labelSize.width)) * reaction.horizontalOffset + + translate( + top = offsetVertical + emojiSize.height, + left = offsetHorizontal + (labelLayoutResult.size.width - emojiSize.width).coerceAtLeast(0f) / 2f + ) { + + // Draw emoji background + drawRoundRect( + color = emojiBackgroundColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - emojiBackgroundSizePx / 2, + y = emojiSize.center.y - emojiBackgroundSizePx / 2, + ), + size = Size(emojiBackgroundSizePx, emojiBackgroundSizePx), + cornerRadius = CornerRadius(20f), + ) + + // Draw emoji + drawText( + textLayoutResult = emojiLayoutResult, + color = Color.Black.copy(alpha = reaction.alpha.value), + ) + + if (reaction.inCallReaction.sender != ReactionSender.Unknown) { + // Draw label background + drawRoundRect( + color = labelColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - labelSize.center.x, + y = emojiSize.height + labelTopMarginPx, + ), + size = labelSize, + cornerRadius = CornerRadius(10f), + ) + + // Draw label text + drawText( + textLayoutResult = labelLayoutResult, + color = labelTextColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - labelLayoutResult.size.center.x, + y = emojiSize.height + labelTopMarginPx + labelTextPaddingPx + ) + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt new file mode 100644 index 00000000000..5472e84cc7d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt @@ -0,0 +1,170 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.incallreactions + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions.reactionDelayMs +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions.defaultReactions +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun InCallReactionsPanel( + onReactionClick: (String) -> Unit, + onMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + val scope = rememberCoroutineScope() + + val disabledReactions = remember { mutableStateListOf() } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = dimensions().spacing8x) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing16x) + ) { + + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + + defaultReactions.forEach { reaction -> + EmojiButton( + isEnabled = disabledReactions.contains(reaction).not(), + emoji = reaction, + onClick = { + onReactionClick(reaction) + disabledReactions.add(reaction) + scope.launch { + delay(reactionDelayMs) + disabledReactions.remove(reaction) + } + } + ) + } + + Icon( + modifier = Modifier.clickable { onMoreClick() }, + painter = painterResource(id = R.drawable.ic_more_emojis), + contentDescription = stringResource(R.string.content_description_more_emojis) + ) + + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + } +} + +@Composable +private fun EmojiButton( + emoji: String, + isEnabled: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(dimensions().inCallReactionButtonSize) + .clip(RoundedCornerShape(dimensions().corner10x)) + .clickable(isEnabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility( + visible = isEnabled, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = colorsScheme().secondaryButtonEnabled, + shape = RoundedCornerShape(dimensions().corner10x) + ), + ) + } + Text( + modifier = Modifier + .alpha(if (isEnabled) 1f else 0.5f), + text = emoji, + fontSize = typography().inCallReactionEmoji.fontSize, + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewEmojiButton() = WireTheme { + EmojiButton( + emoji = defaultReactions[0], + isEnabled = true, + onClick = {} + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewEmojiButtonDisabled() = WireTheme { + EmojiButton( + emoji = defaultReactions[0], + isEnabled = false, + onClick = {} + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewInCallReactionsPanel() = WireTheme { + InCallReactionsPanel( + onReactionClick = {}, + onMoreClick = {}, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt new file mode 100644 index 00000000000..c2e77c5624e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.incallreactions + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.wire.android.ui.calling.model.InCallReaction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import okhttp3.internal.toImmutableList +import kotlin.random.Random + +/** + * Keeps state for currently animated reactions + */ +interface InCallReactionsState { + fun runAnimation(inCallReaction: InCallReaction) + fun getReactions(): List +} + +@Composable +internal fun rememberInCallReactionsState(): InCallReactionsState { + + val scope = rememberCoroutineScope() + + return remember { + InCallReactionsStateImpl( + scope = scope, + mutableReactions = mutableStateListOf(), + ) + } +} + +private class InCallReactionsStateImpl( + private val scope: CoroutineScope, + private val mutableReactions: MutableList, +) : InCallReactionsState { + + /** + * Used by modifier to draw each animated emoji with current animation state + */ + override fun getReactions(): List = mutableReactions.toImmutableList() + + /** + * Adds new emoji to the list of animated emojis. + * Runs animations + * Removes emoji from the list once animations are complete + */ + override fun runAnimation(inCallReaction: InCallReaction) { + scope.launch(Dispatchers.Main) { + val animatable = AnimatableReaction( + inCallReaction = inCallReaction, + horizontalOffset = Random.nextFloat(), + ) + + mutableReactions.add(animatable) + runAnimations(animatable) + mutableReactions.remove(animatable) + } + } + + /** + * Start transition and fade-out animations, wait for complete and return + */ + private suspend fun CoroutineScope.runAnimations(reaction: AnimatableReaction) { + listOf( + launch { + reaction.verticalOffset.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = InCallReactions.animationDurationMs, easing = LinearEasing) + ) + }, + launch { + reaction.alpha.animateTo( + targetValue = 0.0f, + animationSpec = tween( + durationMillis = InCallReactions.fadeOutAnimationDuarationMs, + delayMillis = InCallReactions.animationDurationMs - InCallReactions.fadeOutAnimationDuarationMs + ) + ) + } + ).joinAll() + } +} + +data class AnimatableReaction( + val inCallReaction: InCallReaction, + val verticalOffset: Animatable = Animatable(0f), + val alpha: Animatable = Animatable(1f), + val horizontalOffset: Float, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt new file mode 100644 index 00000000000..02a4d15cc30 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.participantsview + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.wire.android.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +internal fun FlipCameraButton( + isOnFrontCamera: Boolean, + modifier: Modifier = Modifier, + flipCamera: () -> Unit, +) { + Icon( + modifier = modifier + .padding(dimensions().spacing12x) + .size(32.dp) + .background(color = colorsScheme().surface, shape = CircleShape) + .clip(CircleShape) + .clickable { flipCamera() } + .padding(dimensions().spacing6x), + painter = painterResource(R.drawable.ic_flip_camera), + tint = colorsScheme().onSurface, + contentDescription = if (isOnFrontCamera) { + stringResource(R.string.content_description_calling_flip_camera_on) + } else { + stringResource(R.string.content_description_calling_flip_camera_on) + } + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewFlipCameraButton() { + WireTheme { FlipCameraButton(isOnFrontCamera = true) { } } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt index 32dea7ebfb5..09903b86297 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt @@ -60,9 +60,11 @@ fun FloatingSelfUserTile( contentHeight: Dp, contentWidth: Dp, participant: UICallParticipant, + isOnFrontCamera: Boolean, onSelfUserVideoPreviewCreated: (view: View) -> Unit, + onClearSelfUserVideoPreview: () -> Unit, + flipCamera: () -> Unit, modifier: Modifier = Modifier, - onClearSelfUserVideoPreview: () -> Unit ) { var selfVideoTileHeight by remember { mutableStateOf(contentHeight / 4) @@ -170,6 +172,8 @@ fun FloatingSelfUserTile( isSelfUserCameraOn = participant.isCameraOn, onSelfUserVideoPreviewCreated = onSelfUserVideoPreviewCreated, onClearSelfUserVideoPreview = onClearSelfUserVideoPreview, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt index 34dfecc6af2..5a9651f3864 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt @@ -22,9 +22,14 @@ package com.wire.android.ui.calling.ongoing.participantsview import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -56,6 +61,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize @@ -74,11 +80,13 @@ import com.wire.android.R import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.darkColorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography @@ -92,12 +100,15 @@ fun ParticipantTile( isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, onSelfUserVideoPreviewCreated: (view: View) -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, isOnPiPMode: Boolean = false, shouldFillSelfUserCameraPreview: Boolean = false, shouldFillOthersVideoPreview: Boolean = true, isZoomingEnabled: Boolean = false, - onClearSelfUserVideoPreview: () -> Unit + recentReaction: String? = null, + onClearSelfUserVideoPreview: () -> Unit, ) { val alpha = if (participantTitleState.hasEstablishedAudio) ContentAlpha.high else ContentAlpha.medium @@ -107,8 +118,9 @@ fun ParticipantTile( color = darkColorsScheme().surfaceContainer, shape = RoundedCornerShape(if (participantTitleState.isSpeaking) dimensions().corner8x else dimensions().corner3x), ) { + ConstraintLayout { - val (avatar, bottomRow) = createRefs() + val (avatar, bottomRow, cameraButton) = createRefs() val maxAvatarSize = dimensions().onGoingCallUserAvatarSize val activeSpeakerBorderPadding = dimensions().spacing6x @@ -173,6 +185,42 @@ fun ParticipantTile( } ) } + + AnimatedVisibility( + modifier = Modifier + .padding(dimensions().spacing12x), + visible = recentReaction != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .size(dimensions().inCallReactionRecentReactionSize) + .background( + color = colorsScheme().emojiBackgroundColor, + shape = RoundedCornerShape(dimensions().corner6x) + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = recentReaction ?: "", + textAlign = TextAlign.Center, + style = typography().inCallReactionRecentEmoji, + ) + } + } + + if (isSelfUser && isSelfUserCameraOn) { + FlipCameraButton( + modifier = Modifier + .constrainAs(cameraButton) { + top.linkTo(parent.top) + end.linkTo(parent.end) + }, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, + ) + } } } } @@ -464,6 +512,9 @@ private fun PreviewParticipantTile( isSpeaking: Boolean = false, hasEstablishedAudio: Boolean = true, shape: PreviewTileShape = PreviewTileShape.Wide, + recentReaction: String? = null, + isSelfUser: Boolean = false, + isSelfCameraOn: Boolean = false, ) { ParticipantTile( modifier = Modifier.size(width = shape.width, height = shape.height), @@ -482,9 +533,12 @@ private fun PreviewParticipantTile( ), onClearSelfUserVideoPreview = {}, onSelfUserVideoPreviewCreated = {}, - isSelfUser = false, + isSelfUser = isSelfUser, isSelfUserMuted = false, - isSelfUserCameraOn = false + isSelfUserCameraOn = isSelfCameraOn, + recentReaction = recentReaction, + isOnFrontCamera = false, + flipCamera = { }, ) } @@ -569,3 +623,24 @@ fun PreviewParticipantTallLongNameTalking() = WireTheme { fun PreviewParticipantWideLongNameTalking() = WireTheme { PreviewParticipantTile(shape = PreviewTileShape.Wide, isSpeaking = true) } + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantTalkingReaction() = WireTheme { + PreviewParticipantTile( + shape = PreviewTileShape.Regular, + isSpeaking = true, + recentReaction = InCallReactions.defaultReactions[2], + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantCameraButton() = WireTheme { + PreviewParticipantTile( + shape = PreviewTileShape.Regular, + recentReaction = InCallReactions.defaultReactions[2], + isSelfUser = true, + isSelfCameraOn = true, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt index 9aa4550af2c..116b0d996e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt @@ -61,12 +61,15 @@ fun VerticalCallingPager( isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, isInPictureInPictureMode: Boolean, + isOnFrontCamera: Boolean, contentHeight: Dp, currentUserId: UserId, + recentReactions: Map, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, requestVideoStreams: (participants: List) -> Unit, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + flipCamera: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -107,6 +110,9 @@ fun VerticalCallingPager( onSelfClearVideoPreview = onSelfClearVideoPreview, onDoubleTap = onDoubleTap, currentUserId = currentUserId, + recentReactions = recentReactions, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } else { GroupCallGrid( @@ -120,6 +126,9 @@ fun VerticalCallingPager( onDoubleTap = onDoubleTap, currentUserId = currentUserId, isInPictureInPictureMode = isInPictureInPictureMode, + recentReactions = recentReactions, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } @@ -177,8 +186,11 @@ private fun PreviewVerticalCallingPager(participants: List) { onSelfClearVideoPreview = {}, requestVideoStreams = {}, onDoubleTap = { }, + flipCamera = { }, isInPictureInPictureMode = false, currentUserId = participants[0].id, + recentReactions = emptyMap(), + isOnFrontCamera = false, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt index c1208dcf50e..b6ba436e183 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt @@ -51,13 +51,16 @@ fun GroupCallGrid( isSelfUserCameraOn: Boolean, contentHeight: Dp, currentUserId: UserId, + isOnFrontCamera: Boolean, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + flipCamera: () -> Unit, + isInPictureInPictureMode: Boolean, + recentReactions: Map, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, spacedBy: Dp = dimensions().spacing2x, - isInPictureInPictureMode: Boolean, ) { // We need the number of tiles rows needed to calculate their height val numberOfTilesRows = remember(participants.size) { @@ -107,7 +110,10 @@ fun GroupCallGrid( isSelfUserMuted = isSelfUserMuted, isSelfUserCameraOn = isSelfUserCameraOn, onSelfUserVideoPreviewCreated = onSelfVideoPreviewCreated, - onClearSelfUserVideoPreview = onSelfClearVideoPreview + onClearSelfUserVideoPreview = onSelfClearVideoPreview, + recentReaction = recentReactions[participant.id], + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } @@ -141,6 +147,9 @@ private fun PreviewGroupCallGrid(participants: List, modifier onDoubleTap = { }, currentUserId = UserId("id", "domain"), isInPictureInPictureMode = false, + recentReactions = emptyMap(), + isOnFrontCamera = false, + flipCamera = {}, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt index 42dce497b4d..7ebae452b41 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt @@ -52,7 +52,10 @@ fun CallingHorizontalView( currentUserId: UserId, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, + recentReactions: Map, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, spacedBy: Dp = dimensions().spacing2x, @@ -95,6 +98,9 @@ fun CallingHorizontalView( isSelfUserCameraOn = isSelfUserCameraOn, onSelfUserVideoPreviewCreated = onSelfVideoPreviewCreated, onClearSelfUserVideoPreview = onSelfClearVideoPreview, + recentReaction = recentReactions[participant.id], + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } @@ -112,9 +118,12 @@ fun PreviewCallingHorizontalView( isSelfUserCameraOn = false, contentHeight = 800.dp, currentUserId = UserId("id", "domain"), + recentReactions = emptyMap(), onSelfVideoPreviewCreated = {}, onSelfClearVideoPreview = {}, onDoubleTap = { }, + isOnFrontCamera = false, + flipCamera = { }, ) } } diff --git a/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt b/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt new file mode 100644 index 00000000000..4b60e3a4cff --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Map implementation that removes entries after a delay. Not thread-safe. + */ +class ExpiringMap( + private val scope: CoroutineScope, + private val expiration: Long, + private val delegate: MutableMap, + private val currentTime: () -> Long = { System.currentTimeMillis() }, +) : MutableMap by delegate { + + private val timestamps: MutableMap = ConcurrentHashMap() + private var cleanupJob: Job? = null + + override fun put(key: K, value: V): V? { + return delegate.put(key, value).also { + timestamps.put(key, currentTime() + expiration) + scheduleCleanup() + } + } + + override fun remove(key: K): V? { + return delegate.remove(key).also { + timestamps.remove(key) + scheduleCleanup() + } + } + + private fun scheduleCleanup() { + cleanupJob?.cancel() + timestamps.values.sorted().firstOrNull()?.let { nextExpiration -> + val delayToNext = nextExpiration - currentTime() + cleanupJob = scope.launch { + delay(delayToNext) + removeAllExpired() + } + } + } + + private fun removeAllExpired() { + val now = currentTime() + timestamps.entries.onEach { (key, expiration) -> + if (expiration <= now) { + delegate.remove(key) + } + } + timestamps.entries.removeAll { it.value <= now } + scheduleCleanup() + } +} diff --git a/app/src/main/res/drawable/ic_flip_camera.xml b/app/src/main/res/drawable/ic_flip_camera.xml new file mode 100644 index 00000000000..1f0c24e6eb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_flip_camera.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_incall_reactions.xml b/app/src/main/res/drawable/ic_incall_reactions.xml new file mode 100644 index 00000000000..d5b6fefbf03 --- /dev/null +++ b/app/src/main/res/drawable/ic_incall_reactions.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f12f3cbc10a..0239968ec0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,6 +162,8 @@ Turn camera off Turn speaker on Turn speaker off + Show in call reactions panel + Hide in call reactions panel Show more options Reply to the message Cancel message reply @@ -1662,4 +1664,5 @@ In group conversations, the group admin can overwrite this setting. You\'ve created or joined a team with this email address on another device. Wire could not complete your team creation due to a slow internet connection. Wire could not complete your team creation due to an unknown error. + You diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 92937700fdb..147f33cfa94 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.calling import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.calling.OngoingCallViewModelTest.Arrangement import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt index 18e62a81877..1a5deabb5ad 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt @@ -19,12 +19,19 @@ package com.wire.android.ui.calling import android.view.View +import app.cash.turbine.test +import com.wire.android.assertIs import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.TestUser import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger +import com.wire.android.ui.calling.model.ReactionSender +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.data.call.Call +import com.wire.kalium.logic.data.call.InCallReactionMessage import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase @@ -32,6 +39,7 @@ import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveSpeakerUseCase import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase @@ -39,15 +47,19 @@ import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase +import com.wire.kalium.logic.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -93,6 +105,12 @@ class SharedCallingViewModelTest { @MockK private lateinit var observeSpeaker: ObserveSpeakerUseCase + @MockK + lateinit var observeInCallReactionsUseCase: ObserveInCallReactionsUseCase + + @MockK + lateinit var sendInCallReactionUseCase: SendInCallReactionUseCase + @MockK private lateinit var callRinger: CallRinger @@ -111,12 +129,16 @@ class SharedCallingViewModelTest { private lateinit var sharedCallingViewModel: SharedCallingViewModel + val reactionsFlow = MutableSharedFlow() + val callFlow = MutableSharedFlow() + @BeforeEach fun setup() { MockKAnnotations.init(this) - coEvery { establishedCall.invoke(any()) } returns emptyFlow() + coEvery { establishedCall.invoke(any()) } returns callFlow coEvery { observeConversationDetails.invoke(any()) } returns emptyFlow() coEvery { observeSpeaker.invoke() } returns emptyFlow() + coEvery { observeInCallReactionsUseCase(any()) } returns reactionsFlow sharedCallingViewModel = SharedCallingViewModel( conversationId = conversationId, @@ -135,6 +157,8 @@ class SharedCallingViewModelTest { callRinger = callRinger, uiCallParticipantMapper = uiCallParticipantMapper, userTypeMapper = userTypeMapper, + observeInCallReactionsUseCase = observeInCallReactionsUseCase, + sendInCallReactionUseCase = sendInCallReactionUseCase, dispatchers = TestDispatcherProvider() ) } @@ -312,6 +336,97 @@ class SharedCallingViewModelTest { coVerify(exactly = 1) { setVideoPreview(any(), any()) } } + @Test + fun givenAnOngoingCall_WhenInCallReactionIsReceived_ThenNewEmojiIsEmitted() = runTest { + + sharedCallingViewModel.inCallReactions.test { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍", "🎉"))) + + val reaction1 = awaitItem() + val reaction2 = awaitItem() + + // then + assertEquals("👍", reaction1.emoji) + assertIs(reaction1.sender) + assertEquals("🎉", reaction2.emoji) + assertIs(reaction2.sender) + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsReceived_ThenNewRecentReactionEmitted() = runTest { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍"))) + + val recentReaction = sharedCallingViewModel.recentReactions.getValue(TestUser.USER_ID) + + // then + assertEquals("👍", recentReaction) + } + + @Test + fun givenAnOngoingCall_WhenNewInCallReactionIsReceived_ThenRecentReactionUpdated() = runTest { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍", "🎉"))) + + val recentReaction = sharedCallingViewModel.recentReactions.getValue(TestUser.USER_ID) + + // then + assertEquals("🎉", recentReaction) + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsSent_ThenReactionMessageIsSent() = runTest { + + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns Either.Right(Unit) + + // when + sharedCallingViewModel.onReactionClick("👍") + + // then + coVerify(exactly = 1) { + sendInCallReactionUseCase(OngoingCallViewModelTest.Companion.conversationId, "👍") + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsSent_ThenNewEmojiIsEmitted() = runTest { + + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns Either.Right(Unit) + + sharedCallingViewModel.inCallReactions.test { + // when + sharedCallingViewModel.onReactionClick("👍") + + val reaction = awaitItem() + + // then + assertEquals("👍", reaction.emoji) + assertIs(reaction.sender) + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionSentFails_ThenNoEmojiIsEmitted() = runTest { + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns + Either.Left(NetworkFailure.NoNetworkConnection(IllegalStateException())) + + sharedCallingViewModel.inCallReactions.test { + // when + sharedCallingViewModel.onReactionClick("👍") + + // then + expectNoEvents() + } + } + companion object { private val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") } diff --git a/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt b/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt new file mode 100644 index 00000000000..0fa3f809b98 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt @@ -0,0 +1,116 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test + +class ExpiringMapTest { + + @Test + fun `check new item can be added map `() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + + // then + assertEquals("testValue", map["testKey"]) + } + + @Test + fun `check item can be removed from map before expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + map.remove("testKey") + + // then + assertEquals(null, map["testKey"]) + } + + @Test + fun `check item can not be obtained before expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(300) + + // then + assertEquals("testValue", map["testKey"]) + } + + @Test + fun `check item can not be obtained after expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(301) + + // then + assertEquals(null, map["testKey"]) + } + + @Test + fun `check adding item with existing key resets expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(300) + map.put("testKey", "testValue2") + advanceTimeBy(300) + + // then + assertEquals("testValue2", map["testKey"]) + } + + @Test + fun `check adding item with non-existing key keeps expiration for other keys`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(200) + map.put("testKey2", "testValue2") + advanceTimeBy(200) + + // then + assertEquals(null, map["testKey"]) + } + + private fun TestScope.withTestExpiringMap(): MutableMap = ExpiringMap( + scope = this.backgroundScope, + expiration = 300, + delegate = mutableMapOf(), + currentTime = { currentTime } + ) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index e6599228ef0..bfcd31393dc 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -81,6 +81,8 @@ class WireColorScheme( // accents val groupAvatarColors: List, val wireAccentColors: WireAccentColors, + + val emojiBackgroundColor: Color, ) { fun toColorScheme(): ColorScheme = ColorScheme( primary = primary, onPrimary = onPrimary, @@ -182,6 +184,7 @@ private val LightWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.LightBlue500 } }, + emojiBackgroundColor = Color.White, ) // Dark WireColorScheme @@ -257,6 +260,7 @@ private val DarkWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.DarkBlue500 } }, + emojiBackgroundColor = Color.White, ) @PackagePrivate diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 324e9477336..d88a5b59776 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -107,6 +107,8 @@ data class WireDimensions( val badgeSmallMinSize: DpSize, val badgeSmallMinClickableSize: DpSize, val onMoreOptionsButtonCornerRadius: Dp, + val inCallReactionButtonSize: Dp, + val inCallReactionRecentReactionSize: Dp, // Dialog val dialogButtonsSpacing: Dp, val dialogTextsSpacing: Dp, @@ -183,6 +185,8 @@ data class WireDimensions( val groupButtonHeight: Dp, // Calling val defaultCallingControlsSize: Dp, + val defaultCallingControlsHeight: Dp, + val defaultCallingControlsWidth: Dp, val defaultCallingControlsIconSize: Dp, val bigCallingControlsSize: Dp, val bigCallingHangUpButtonIconSize: Dp, @@ -341,11 +345,13 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( systemMessageIconLargeSize = 18.dp, groupButtonHeight = 82.dp, defaultCallingControlsSize = 56.dp, + defaultCallingControlsHeight = 40.dp, + defaultCallingControlsWidth = 56.dp, defaultCallingControlsIconSize = 20.dp, bigCallingControlsSize = 72.dp, bigCallingHangUpButtonIconSize = 32.dp, bigCallingAcceptButtonIconSize = 24.dp, - defaultSheetPeekHeight = 100.dp, + defaultSheetPeekHeight = 72.dp, defaultOutgoingCallSheetPeekHeight = 281.dp, onGoingCallUserAvatarSize = 72.dp, onGoingCallTileUsernameMaxWidth = 120.dp, @@ -360,6 +366,8 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( importedMediaAssetSize = 120.dp, typingIndicatorHeight = 24.dp, legalHoldBannerMinHeight = 26.dp, + inCallReactionButtonSize = 48.dp, + inCallReactionRecentReactionSize = 32.dp, ) private val DefaultPhoneLandscapeWireDimensions: WireDimensions = DefaultPhonePortraitWireDimensions diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt index 79da10ada0b..e1f290c34d5 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt @@ -37,7 +37,9 @@ data class WireTypography( val label01: TextStyle, val label02: TextStyle, val label03: TextStyle, val label04: TextStyle, val label05: TextStyle, val badge01: TextStyle, val subline01: TextStyle, - val code01: TextStyle + val code01: TextStyle, + val inCallReactionEmoji: TextStyle, + val inCallReactionRecentEmoji: TextStyle, ) { fun toTypography() = Typography( titleLarge = title01, titleMedium = title02, titleSmall = title03, @@ -69,7 +71,9 @@ private val DefaultWireTypography = WireTypography( label05 = WireTypographyBase.Label05, badge01 = WireTypographyBase.Badge01, subline01 = WireTypographyBase.SubLine01, - code01 = WireTypographyBase.Code01 + code01 = WireTypographyBase.Code01, + inCallReactionEmoji = WireTypographyBase.InCallEmoji, + inCallReactionRecentEmoji = WireTypographyBase.InCallEmojiRecent, ) @PackagePrivate diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt index 0a12f33559e..e8ef201ffd2 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt @@ -148,4 +148,10 @@ object WireTypographyBase { lineHeight = 28.13.sp, textAlign = TextAlign.Center ) + val InCallEmoji = TextStyle( + fontSize = 32.sp, + ) + val InCallEmojiRecent = TextStyle( + fontSize = 20.sp, + ) } diff --git a/kalium b/kalium index 91b8319e99d..d168aa2c17c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 91b8319e99d83e486751967c8adf4f027d57a82e +Subproject commit d168aa2c17c460263910294b80f9ca402baaf4cb