Skip to content

Commit e23e337

Browse files
committed
feat: send and receive in-call reactions [#WPB-14254]
1 parent 22a0b22 commit e23e337

40 files changed

+1513
-196
lines changed

app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,9 @@ class CallsModule {
202202
@Provides
203203
fun provideObserveConferenceCallingEnabledUseCase(callsScope: CallsScope) =
204204
callsScope.observeConferenceCallingEnabled
205+
206+
@ViewModelScoped
207+
@Provides
208+
fun provideObserveInCallReactionsUseCase(callsScope: CallsScope) =
209+
callsScope.observeInCallReactions
205210
}

app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.wire.kalium.logic.feature.asset.ObserveAssetStatusesUseCase
2828
import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages
2929
import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase
3030
import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCase
31+
import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase
3132
import com.wire.kalium.logic.feature.message.DeleteMessageUseCase
3233
import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase
3334
import com.wire.kalium.logic.feature.message.GetNotificationsUseCase
@@ -216,4 +217,9 @@ class MessageModule {
216217
@Provides
217218
fun provideRemoveMessageDraftUseCase(messageScope: MessageScope): RemoveMessageDraftUseCase =
218219
messageScope.removeMessageDraftUseCase
220+
221+
@ViewModelScoped
222+
@Provides
223+
fun provideSendInCallReactionUseCase(messageScope: MessageScope): SendInCallReactionUseCase =
224+
messageScope.sendInCallReactionUseCase
219225
}

app/src/main/kotlin/com/wire/android/mapper/UICallParticipantMapper.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ package com.wire.android.mapper
2121
import com.wire.android.model.ImageAsset
2222
import com.wire.android.ui.calling.model.UICallParticipant
2323
import com.wire.kalium.logic.data.call.Participant
24+
import com.wire.kalium.logic.data.conversation.ClientId
2425
import javax.inject.Inject
2526

2627
class UICallParticipantMapper @Inject constructor(
2728
private val userTypeMapper: UserTypeMapper,
2829
) {
29-
fun toUICallParticipant(participant: Participant) = UICallParticipant(
30+
fun toUICallParticipant(participant: Participant, currentClientId: ClientId) = UICallParticipant(
3031
id = participant.id,
3132
clientId = participant.clientId,
33+
isSelfUser = participant.clientId == currentClientId.value,
3234
name = participant.name,
3335
isMuted = participant.isMuted,
3436
isSpeaking = participant.isSpeaking,

app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package com.wire.android.ui.calling
2020

2121
import android.view.View
2222
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableStateMapOf
2324
import androidx.compose.runtime.mutableStateOf
2425
import androidx.compose.runtime.setValue
2526
import androidx.lifecycle.ViewModel
@@ -29,26 +30,37 @@ import com.wire.android.mapper.UICallParticipantMapper
2930
import com.wire.android.mapper.UserTypeMapper
3031
import com.wire.android.media.CallRinger
3132
import com.wire.android.model.ImageAsset
33+
import com.wire.android.ui.calling.model.InCallReaction
34+
import com.wire.android.ui.calling.model.ReactionSender
3235
import com.wire.android.ui.calling.model.UICallParticipant
36+
import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions
37+
import com.wire.android.util.ExpiringMap
3338
import com.wire.android.util.dispatchers.DispatcherProvider
39+
import com.wire.android.util.extension.withDelayAfterFirst
3440
import com.wire.kalium.logic.data.call.Call
3541
import com.wire.kalium.logic.data.call.ConversationTypeForCall
3642
import com.wire.kalium.logic.data.call.VideoState
3743
import com.wire.kalium.logic.data.conversation.Conversation
3844
import com.wire.kalium.logic.data.conversation.ConversationDetails
3945
import com.wire.kalium.logic.data.id.ConversationId
46+
import com.wire.kalium.logic.data.id.QualifiedID
47+
import com.wire.kalium.logic.data.user.UserId
4048
import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase
4149
import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase
4250
import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase
4351
import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase
4452
import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase
53+
import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase
4554
import com.wire.kalium.logic.feature.call.usecase.ObserveSpeakerUseCase
4655
import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase
4756
import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase
4857
import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase
4958
import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase
5059
import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase
60+
import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase
5161
import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase
62+
import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase
63+
import com.wire.kalium.logic.functional.onSuccess
5264
import com.wire.kalium.logic.util.PlatformView
5365
import dagger.assisted.Assisted
5466
import dagger.assisted.AssistedFactory
@@ -57,13 +69,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
5769
import kotlinx.collections.immutable.persistentListOf
5870
import kotlinx.collections.immutable.toPersistentList
5971
import kotlinx.coroutines.CoroutineScope
72+
import kotlinx.coroutines.channels.BufferOverflow
73+
import kotlinx.coroutines.channels.Channel
6074
import kotlinx.coroutines.flow.SharedFlow
6175
import kotlinx.coroutines.flow.SharingStarted
6276
import kotlinx.coroutines.flow.collectLatest
77+
import kotlinx.coroutines.flow.combine
6378
import kotlinx.coroutines.flow.filterIsInstance
79+
import kotlinx.coroutines.flow.filterNotNull
6480
import kotlinx.coroutines.flow.first
6581
import kotlinx.coroutines.flow.flowOn
6682
import kotlinx.coroutines.flow.map
83+
import kotlinx.coroutines.flow.receiveAsFlow
6784
import kotlinx.coroutines.flow.shareIn
6885
import kotlinx.coroutines.launch
6986

@@ -83,6 +100,9 @@ class SharedCallingViewModel @AssistedInject constructor(
83100
private val flipToFrontCamera: FlipToFrontCameraUseCase,
84101
private val flipToBackCamera: FlipToBackCameraUseCase,
85102
private val observeSpeaker: ObserveSpeakerUseCase,
103+
private val observeInCallReactionsUseCase: ObserveInCallReactionsUseCase,
104+
private val sendInCallReactionUseCase: SendInCallReactionUseCase,
105+
private val getCurrentClientId: ObserveCurrentClientIdUseCase,
86106
private val callRinger: CallRinger,
87107
private val uiCallParticipantMapper: UICallParticipantMapper,
88108
private val userTypeMapper: UserTypeMapper,
@@ -93,6 +113,15 @@ class SharedCallingViewModel @AssistedInject constructor(
93113

94114
var participantsState by mutableStateOf(persistentListOf<UICallParticipant>())
95115

116+
private val _inCallReactions = Channel<InCallReaction>(
117+
capacity = 300, // Max reactions to keep in queue
118+
onBufferOverflow = BufferOverflow.DROP_OLDEST,
119+
)
120+
121+
val inCallReactions = _inCallReactions.receiveAsFlow().withDelayAfterFirst(InCallReactions.reactionsThrottleDelayMs)
122+
123+
val recentReactions = recentInCallReactionMap()
124+
96125
init {
97126
viewModelScope.launch {
98127
val allCallsSharedFlow = observeEstablishedCallWithSortedParticipants(conversationId)
@@ -110,6 +139,9 @@ class SharedCallingViewModel @AssistedInject constructor(
110139
launch {
111140
observeOnSpeaker(this)
112141
}
142+
launch {
143+
observeInCallReactions()
144+
}
113145
}
114146
}
115147

@@ -172,18 +204,22 @@ class SharedCallingViewModel @AssistedInject constructor(
172204
}
173205

174206
private suspend fun observeParticipants(sharedFlow: SharedFlow<Call?>) {
175-
sharedFlow.collectLatest { call ->
176-
call?.let {
207+
combine(
208+
getCurrentClientId().filterNotNull(),
209+
sharedFlow.filterNotNull(),
210+
) { clientId, call -> clientId to call }
211+
.collectLatest { (clientId, call) ->
177212
callState = callState.copy(
178-
isMuted = it.isMuted,
179-
callStatus = it.status,
180-
isCameraOn = it.isCameraOn,
181-
isCbrEnabled = it.isCbrEnabled && call.conversationType == Conversation.Type.ONE_ON_ONE,
182-
callerName = it.callerName,
213+
isMuted = call.isMuted,
214+
callStatus = call.status,
215+
isCameraOn = call.isCameraOn,
216+
isCbrEnabled = call.isCbrEnabled && call.conversationType == Conversation.Type.ONE_ON_ONE,
217+
callerName = call.callerName,
183218
)
184-
participantsState = call.participants.map { uiCallParticipantMapper.toUICallParticipant(it) }.toPersistentList()
219+
participantsState = call.participants.map {
220+
uiCallParticipantMapper.toUICallParticipant(it, clientId)
221+
}.toPersistentList()
185222
}
186-
}
187223
}
188224

189225
fun hangUpCall(onCompleted: () -> Unit) {
@@ -279,8 +315,42 @@ class SharedCallingViewModel @AssistedInject constructor(
279315
}
280316
}
281317

318+
private suspend fun observeInCallReactions() {
319+
observeInCallReactionsUseCase(conversationId).collect { message ->
320+
321+
val sender = participantsState.senderName(message.senderUserId)?.let { name ->
322+
ReactionSender.Other(name)
323+
} ?: ReactionSender.Unknown
324+
325+
message.emojis.forEach { emoji ->
326+
_inCallReactions.send(InCallReaction(emoji, sender))
327+
}
328+
329+
if (message.emojis.isNotEmpty()) {
330+
recentReactions.put(message.senderUserId, message.emojis.last())
331+
}
332+
}
333+
}
334+
335+
fun onReactionClick(emoji: String) {
336+
viewModelScope.launch {
337+
sendInCallReactionUseCase(conversationId, emoji).onSuccess {
338+
_inCallReactions.send(InCallReaction(emoji, ReactionSender.You))
339+
}
340+
}
341+
}
342+
343+
private fun recentInCallReactionMap(): MutableMap<UserId, String> =
344+
ExpiringMap<UserId, String>(
345+
scope = viewModelScope,
346+
expiration = InCallReactions.recentReactionShowDurationMs,
347+
delegate = mutableStateMapOf<UserId, String>()
348+
)
349+
282350
@AssistedFactory
283351
interface Factory {
284352
fun create(conversationId: ConversationId): SharedCallingViewModel
285353
}
286354
}
355+
356+
private fun List<UICallParticipant>.senderName(userId: QualifiedID) = firstOrNull { it.id.value == userId.value }?.name

app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,8 @@ package com.wire.android.ui.calling.controlbuttons
2020

2121
import androidx.compose.runtime.Composable
2222
import androidx.compose.ui.Modifier
23-
import androidx.compose.ui.unit.Dp
2423
import com.wire.android.R
2524
import com.wire.android.appLogger
26-
import com.wire.android.ui.common.dimensions
2725
import com.wire.android.ui.theme.WireTheme
2826
import com.wire.android.util.permission.rememberCameraPermissionFlow
2927
import com.wire.android.util.ui.PreviewMultipleThemes
@@ -34,7 +32,6 @@ fun CameraButton(
3432
onPermissionPermanentlyDenied: () -> Unit,
3533
modifier: Modifier = Modifier,
3634
isCameraOn: Boolean = false,
37-
size: Dp = dimensions().defaultCallingControlsSize,
3835
) {
3936
val cameraPermissionCheck = rememberCameraPermissionFlow(
4037
onPermissionGranted = {
@@ -56,7 +53,6 @@ fun CameraButton(
5653
false -> R.string.content_description_calling_turn_camera_on
5754
},
5855
onClick = cameraPermissionCheck::launch,
59-
size = size,
6056
modifier = modifier,
6157
)
6258
}

app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes
3838
fun HangUpButton(
3939
onHangUpButtonClicked: () -> Unit,
4040
modifier: Modifier = Modifier,
41-
size: Dp = dimensions().bigCallingControlsSize,
41+
size: Dp = dimensions().defaultCallingControlsHeight,
4242
iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize,
4343
) {
4444
WirePrimaryIconButton(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2024 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*/
18+
19+
package com.wire.android.ui.calling.controlbuttons
20+
21+
import androidx.compose.foundation.layout.height
22+
import androidx.compose.foundation.layout.width
23+
import androidx.compose.foundation.shape.CircleShape
24+
import androidx.compose.material3.MaterialTheme
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.ui.Modifier
27+
import androidx.compose.ui.unit.Dp
28+
import androidx.compose.ui.unit.DpSize
29+
import com.wire.android.R
30+
import com.wire.android.ui.common.button.WireButtonState
31+
import com.wire.android.ui.common.button.WirePrimaryIconButton
32+
import com.wire.android.ui.common.dimensions
33+
import com.wire.android.ui.theme.WireTheme
34+
import com.wire.android.ui.theme.wireDimensions
35+
import com.wire.android.util.ui.PreviewMultipleThemes
36+
37+
@Composable
38+
fun HangUpOngoingButton(
39+
onHangUpButtonClicked: () -> Unit,
40+
modifier: Modifier = Modifier,
41+
width: Dp = dimensions().defaultCallingControlsWidth,
42+
height: Dp = dimensions().defaultCallingControlsHeight,
43+
iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize,
44+
) {
45+
WirePrimaryIconButton(
46+
iconResource = R.drawable.ic_call_reject,
47+
contentDescription = R.string.content_description_calling_hang_up_call,
48+
state = WireButtonState.Error,
49+
shape = CircleShape,
50+
minSize = DpSize(width, height),
51+
minClickableSize = DpSize(width, height),
52+
iconSize = iconSize,
53+
onButtonClicked = onHangUpButtonClicked,
54+
modifier = modifier,
55+
)
56+
}
57+
58+
@PreviewMultipleThemes
59+
@Composable
60+
fun PreviewComposableHangUpOngoingButton() = WireTheme {
61+
HangUpOngoingButton(
62+
modifier = Modifier
63+
.width(MaterialTheme.wireDimensions.bigCallingControlsSize)
64+
.height(MaterialTheme.wireDimensions.bigCallingControlsSize),
65+
onHangUpButtonClicked = { }
66+
)
67+
}

app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt renamed to app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,48 +15,49 @@
1515
* You should have received a copy of the GNU General Public License
1616
* along with this program. If not, see http://www.gnu.org/licenses/.
1717
*/
18-
1918
package com.wire.android.ui.calling.controlbuttons
2019

2120
import androidx.compose.runtime.Composable
2221
import androidx.compose.ui.Modifier
23-
import androidx.compose.ui.unit.Dp
2422
import com.wire.android.R
25-
import com.wire.android.ui.common.dimensions
2623
import com.wire.android.ui.theme.WireTheme
2724
import com.wire.android.util.ui.PreviewMultipleThemes
2825

2926
@Composable
30-
fun CameraFlipButton(
31-
onCameraFlipButtonClicked: () -> Unit,
27+
fun InCallReactionsButton(
28+
isSelected: Boolean,
29+
onInCallReactionsClick: () -> Unit,
3230
modifier: Modifier = Modifier,
33-
isOnFrontCamera: Boolean = false,
34-
size: Dp = dimensions().defaultCallingControlsSize
3531
) {
3632
WireCallControlButton(
37-
isSelected = !isOnFrontCamera,
38-
iconResId = when (isOnFrontCamera) {
39-
true -> R.drawable.ic_camera_flipped
40-
false -> R.drawable.ic_camera_flip
33+
isSelected = isSelected,
34+
iconResId = when (isSelected) {
35+
true -> R.drawable.ic_incall_reactions
36+
false -> R.drawable.ic_incall_reactions
4137
},
42-
contentDescription = when (isOnFrontCamera) {
43-
true -> R.string.content_description_calling_flip_camera_on
44-
false -> R.string.content_description_calling_flip_camera_off
38+
contentDescription = when (isSelected) {
39+
true -> R.string.content_description_calling_unmute_call
40+
false -> R.string.content_description_calling_mute_call
4541
},
46-
onClick = onCameraFlipButtonClicked,
47-
size = size,
48-
modifier = modifier,
42+
onClick = onInCallReactionsClick,
43+
modifier = modifier
4944
)
5045
}
5146

5247
@PreviewMultipleThemes
5348
@Composable
54-
fun PreviewCameraFlipButtonOn() = WireTheme {
55-
CameraFlipButton(isOnFrontCamera = true, onCameraFlipButtonClicked = { })
49+
fun PreviewInCallReactionsButton() = WireTheme {
50+
InCallReactionsButton(
51+
isSelected = false,
52+
onInCallReactionsClick = { }
53+
)
5654
}
5755

5856
@PreviewMultipleThemes
5957
@Composable
60-
fun PreviewCameraFlipButtonOff() = WireTheme {
61-
CameraFlipButton(isOnFrontCamera = false, onCameraFlipButtonClicked = { })
58+
fun PreviewInCallReactionsButtonSelected() = WireTheme {
59+
InCallReactionsButton(
60+
isSelected = true,
61+
onInCallReactionsClick = { }
62+
)
6263
}

0 commit comments

Comments
 (0)