mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Address design feedback for calling UI.
This commit is contained in:
committed by
jeffrey-signal
parent
acd82353b1
commit
b3b934e009
@@ -41,6 +41,14 @@ fun BadgeImageMedium(
|
||||
BadgeImage(badge, BadgeImageSize.MEDIUM, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImageLarge(
|
||||
badge: Badge?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BadgeImage(badge, BadgeImageSize.LARGE, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImage112(
|
||||
badge: Badge?,
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.util.visible
|
||||
/**
|
||||
* An indicator shown for each participant in a call which shows the state of their audio.
|
||||
*/
|
||||
class AudioIndicatorView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) {
|
||||
class AudioIndicatorView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val SIDE_BAR_SHRINK_FACTOR = 0.75f
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc.controls
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -44,7 +43,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.toLiveData
|
||||
@@ -54,6 +52,7 @@ import io.reactivex.rxjava3.core.Observable
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.NightPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -108,20 +107,14 @@ object CallInfoView {
|
||||
}
|
||||
}
|
||||
|
||||
SignalTheme(
|
||||
isDarkMode = true
|
||||
) {
|
||||
Surface {
|
||||
CallInfo(
|
||||
participantsState = participantsState,
|
||||
controlAndInfoState = controlAndInfoState,
|
||||
onShareLinkClicked = callbacks::onShareLinkClicked,
|
||||
onEditNameClicked = onEditNameClicked,
|
||||
onBlock = callbacks::onBlock,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
CallInfo(
|
||||
participantsState = participantsState,
|
||||
controlAndInfoState = controlAndInfoState,
|
||||
onShareLinkClicked = callbacks::onShareLinkClicked,
|
||||
onEditNameClicked = onEditNameClicked,
|
||||
onBlock = callbacks::onBlock,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
@@ -131,20 +124,18 @@ object CallInfoView {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallInfoPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
val remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))
|
||||
CallInfo(
|
||||
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }),
|
||||
controlAndInfoState = ControlAndInfoState(),
|
||||
onShareLinkClicked = { },
|
||||
onEditNameClicked = { },
|
||||
onBlock = { }
|
||||
)
|
||||
}
|
||||
val remoteParticipants = listOf(CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")))
|
||||
CallInfo(
|
||||
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }),
|
||||
controlAndInfoState = ControlAndInfoState(),
|
||||
onShareLinkClicked = { },
|
||||
onEditNameClicked = { },
|
||||
onBlock = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,20 +331,20 @@ private fun getCallSheetLabel(state: ParticipantsState): String {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantRowPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
CallParticipantRow(
|
||||
CallParticipant(recipient = Recipient.UNKNOWN),
|
||||
CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
|
||||
isSelfAdmin = true
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun HandRaisedRowPreview() {
|
||||
Previews.Preview {
|
||||
@@ -636,7 +627,7 @@ private fun ThreeUnknownAvatars() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun UnknownMembersRowPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
@@ -57,6 +58,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsVisibilityListener
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
|
||||
import org.thoughtcrime.securesms.compose.SignalTheme
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
@@ -244,7 +246,14 @@ class ControlsAndInfoController private constructor(
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val nestedScrollInterop = rememberNestedScrollInteropConnection()
|
||||
CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop))
|
||||
|
||||
SignalTheme(
|
||||
isDarkMode = true
|
||||
) {
|
||||
Surface {
|
||||
CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,9 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantView
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState
|
||||
import org.webrtc.RendererCommon
|
||||
|
||||
/**
|
||||
* Displays video for the local participant or an appropriate avatar.
|
||||
@@ -31,46 +17,21 @@ fun CallParticipantRenderer(
|
||||
callParticipant: CallParticipant,
|
||||
renderInPip: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isLocalParticipant: Boolean = false,
|
||||
isRaiseHandAllowed: Boolean = false,
|
||||
selfPipMode: CallParticipantView.SelfPipMode = CallParticipantView.SelfPipMode.NOT_SELF_PIP,
|
||||
selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP,
|
||||
onToggleCameraDirection: () -> Unit = {}
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.background(color = MaterialTheme.colorScheme.secondaryContainer)
|
||||
) {
|
||||
Text(
|
||||
text = "${callParticipant.callParticipantId.recipientId.toLong()}",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { LayoutInflater.from(it).inflate(R.layout.call_participant_item, FrameLayout(it), false) as CallParticipantView },
|
||||
modifier = modifier.fillMaxSize(),
|
||||
onRelease = { it.releaseRenderer() },
|
||||
onReset = {} // Allows reuse in lazy lists
|
||||
) { view ->
|
||||
view.setCallParticipant(callParticipant)
|
||||
view.setMirror(isLocalParticipant && callParticipant.cameraState.activeDirection == CameraState.Direction.FRONT)
|
||||
view.setScalingType(
|
||||
if (callParticipant.isScreenSharing && !isLocalParticipant) {
|
||||
RendererCommon.ScalingType.SCALE_ASPECT_FIT
|
||||
} else {
|
||||
RendererCommon.ScalingType.SCALE_ASPECT_FILL
|
||||
}
|
||||
)
|
||||
view.setRenderInPip(renderInPip)
|
||||
view.setRaiseHandAllowed(isRaiseHandAllowed)
|
||||
|
||||
if (selfPipMode != CallParticipantView.SelfPipMode.NOT_SELF_PIP) {
|
||||
view.setSelfPipMode(selfPipMode, callParticipant.cameraState.cameraCount > 1)
|
||||
view.setCameraToggleOnClickListener { onToggleCameraDirection() }
|
||||
} else {
|
||||
view.setCameraToggleOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
CallParticipantViewer(
|
||||
participant = callParticipant,
|
||||
renderInPip = renderInPip,
|
||||
raiseHandAllowed = isRaiseHandAllowed,
|
||||
selfPipMode = selfPipMode,
|
||||
isMoreThanOneCameraAvailable = callParticipant.cameraState.cameraCount > 1,
|
||||
onSwitchCameraClick = if (selfPipMode != SelfPipMode.NOT_SELF_PIP) {
|
||||
{ onToggleCameraDirection() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.NightPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge
|
||||
import org.thoughtcrime.securesms.components.webrtc.AudioIndicatorView
|
||||
import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil
|
||||
|
||||
/**
|
||||
* Encapsulates views needed to show a call participant including their
|
||||
* avatar in full screen or pip mode, and their video feed.
|
||||
*
|
||||
* This is a Compose reimplementation of [org.thoughtcrime.securesms.components.webrtc.CallParticipantView].
|
||||
*/
|
||||
@Composable
|
||||
fun CallParticipantViewer(
|
||||
participant: CallParticipant,
|
||||
modifier: Modifier = Modifier,
|
||||
renderInPip: Boolean = false,
|
||||
raiseHandAllowed: Boolean = false,
|
||||
selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP,
|
||||
isMoreThanOneCameraAvailable: Boolean = false,
|
||||
onSwitchCameraClick: (() -> Unit)? = null,
|
||||
onInfoMoreInfoClick: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val recipient = participant.recipient
|
||||
val isBlocked = recipient.isBlocked
|
||||
val isMissingMediaKeys = !participant.isMediaKeysReceived &&
|
||||
(System.currentTimeMillis() - participant.addedToCallTime) > 5000
|
||||
val infoMode = isBlocked || isMissingMediaKeys
|
||||
|
||||
Box(modifier = modifier) {
|
||||
BlurredBackgroundAvatar(recipient = recipient)
|
||||
|
||||
if (infoMode) {
|
||||
InfoOverlay(
|
||||
recipient = recipient,
|
||||
isBlocked = isBlocked,
|
||||
renderInPip = renderInPip,
|
||||
onMoreInfoClick = onInfoMoreInfoClick
|
||||
)
|
||||
} else {
|
||||
val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing
|
||||
|
||||
if (hasContentToRender) {
|
||||
VideoRenderer(
|
||||
participant = participant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
if (!renderInPip) {
|
||||
AvatarWithBadge(
|
||||
recipient = recipient,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
if (renderInPip) {
|
||||
PipAvatar(
|
||||
recipient = recipient,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AudioIndicator(
|
||||
participant = participant,
|
||||
selfPipMode = selfPipMode,
|
||||
modifier = Modifier.align(
|
||||
when (selfPipMode) {
|
||||
SelfPipMode.MINI_SELF_PIP -> Alignment.BottomCenter
|
||||
else -> Alignment.BottomStart
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (selfPipMode != SelfPipMode.NOT_SELF_PIP && isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) {
|
||||
SwitchCameraButton(
|
||||
selfPipMode = selfPipMode,
|
||||
onClick = onSwitchCameraClick,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
|
||||
if (raiseHandAllowed && participant.isHandRaised) {
|
||||
RaiseHandIndicator(
|
||||
name = participant.getShortRecipientDisplayName(context),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 8.dp, top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlurredBackgroundAvatar(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isInPreview = LocalInspectionMode.current
|
||||
|
||||
// Use a simple background in preview mode, otherwise use Glide to load the blurred avatar
|
||||
if (isInPreview) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF1B1B1D))
|
||||
)
|
||||
} else {
|
||||
// Use AndroidView to leverage AvatarUtil.loadBlurredIconIntoImageView
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
androidx.appcompat.widget.AppCompatImageView(context).apply {
|
||||
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
}
|
||||
},
|
||||
update = { imageView ->
|
||||
AvatarUtil.loadBlurredIconIntoImageView(recipient, imageView)
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarWithBadge(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
AvatarImage(
|
||||
recipient = recipient,
|
||||
modifier = Modifier.size(112.dp)
|
||||
)
|
||||
|
||||
BadgeImageLarge(
|
||||
badge = recipient.badges.firstOrNull(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PipAvatar(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Use the smaller dimension to maintain 1:1 aspect ratio
|
||||
val avatarModifier = if (maxWidth < maxHeight) {
|
||||
Modifier
|
||||
.width(maxWidth)
|
||||
.aspectRatio(1f)
|
||||
} else {
|
||||
Modifier
|
||||
.height(maxHeight)
|
||||
.aspectRatio(1f, true)
|
||||
}
|
||||
|
||||
AvatarImage(
|
||||
recipient = recipient,
|
||||
modifier = avatarModifier
|
||||
)
|
||||
|
||||
BadgeImageLarge(
|
||||
badge = recipient.badges.firstOrNull(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoRenderer(
|
||||
participant: CallParticipant,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var renderer by remember { mutableStateOf<TextureViewRenderer?>(null) }
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
FrameLayout(context).apply {
|
||||
setBackgroundColor(android.graphics.Color.parseColor("#CC000000"))
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
val textureRenderer = TextureViewRenderer(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
Gravity.CENTER
|
||||
)
|
||||
|
||||
if (participant.isVideoEnabled) {
|
||||
participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase ->
|
||||
init(eglBase)
|
||||
}
|
||||
attachBroadcastVideoSink(participant.videoSink)
|
||||
}
|
||||
}
|
||||
|
||||
renderer = textureRenderer
|
||||
addView(textureRenderer)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
val textureRenderer = renderer
|
||||
if (textureRenderer != null) {
|
||||
if (participant.isVideoEnabled) {
|
||||
participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase ->
|
||||
textureRenderer.init(eglBase)
|
||||
}
|
||||
textureRenderer.attachBroadcastVideoSink(participant.videoSink)
|
||||
} else {
|
||||
textureRenderer.attachBroadcastVideoSink(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
renderer?.release()
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioIndicator(
|
||||
participant: CallParticipant,
|
||||
selfPipMode: SelfPipMode,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val margin = when (selfPipMode) {
|
||||
SelfPipMode.NORMAL_SELF_PIP -> 10.dp
|
||||
SelfPipMode.EXPANDED_SELF_PIP -> 10.dp
|
||||
SelfPipMode.MINI_SELF_PIP -> 10.dp
|
||||
SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
|
||||
SelfPipMode.NOT_SELF_PIP -> 12.dp
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
AudioIndicatorView(context, null)
|
||||
},
|
||||
update = { view ->
|
||||
view.bind(participant.isMicrophoneEnabled, participant.audioLevel)
|
||||
},
|
||||
modifier = modifier
|
||||
.padding(margin)
|
||||
.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchCameraButton(
|
||||
selfPipMode: SelfPipMode,
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val size = when (selfPipMode) {
|
||||
SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 48.dp
|
||||
else -> 28.dp
|
||||
}
|
||||
|
||||
val margin = when (selfPipMode) {
|
||||
SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
|
||||
else -> 10.dp
|
||||
}
|
||||
|
||||
val iconInset = when (selfPipMode) {
|
||||
SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
|
||||
SelfPipMode.MINI_SELF_PIP -> 7.dp
|
||||
else -> 6.dp
|
||||
}
|
||||
|
||||
// Only clickable in EXPANDED_SELF_PIP mode (per setSelfPipMode logic)
|
||||
val clickModifier = if (selfPipMode == SelfPipMode.EXPANDED_SELF_PIP && onClick != null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(end = margin, bottom = margin)
|
||||
.size(size)
|
||||
.background(
|
||||
color = Color(0xFF383838),
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(iconInset)
|
||||
.then(clickModifier),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_switch_24),
|
||||
contentDescription = "Switch camera direction",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RaiseHandIndicator(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = colorResource(R.color.signal_light_colorSurface),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_raise_hand_24),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified, // Let the drawable use its default color
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
color = colorResource(R.color.signal_light_colorOnPrimary),
|
||||
fontSize = 14.sp,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoOverlay(
|
||||
recipient: Recipient,
|
||||
isBlocked: Boolean,
|
||||
renderInPip: Boolean,
|
||||
onMoreInfoClick: (() -> Unit)?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0x66000000)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (isBlocked) R.drawable.ic_block_tinted_24 else R.drawable.ic_error_solid_24
|
||||
),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
if (!renderInPip) {
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
// Use AndroidView for EmojiTextView
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
EmojiTextView(ctx).apply {
|
||||
setTextColor(android.graphics.Color.WHITE)
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
maxLines = 3
|
||||
setPadding(
|
||||
context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter),
|
||||
0,
|
||||
context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter),
|
||||
0
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.text = if (isBlocked) {
|
||||
context.getString(
|
||||
R.string.CallParticipantView__s_is_blocked,
|
||||
recipient.getShortDisplayName(context)
|
||||
)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.CallParticipantView__cant_receive_audio_video_from_s,
|
||||
recipient.getShortDisplayName(context)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
Buttons.Small(
|
||||
onClick = { onMoreInfoClick?.invoke() },
|
||||
modifier = Modifier
|
||||
) {
|
||||
Text(text = stringResource(R.string.CallParticipantView__more_info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SelfPipMode {
|
||||
NOT_SELF_PIP,
|
||||
NORMAL_SELF_PIP,
|
||||
EXPANDED_SELF_PIP,
|
||||
MINI_SELF_PIP,
|
||||
FOCUSED_SELF_PIP
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Alice Johnson"),
|
||||
isMicrophoneEnabled = true,
|
||||
audioLevel = CallParticipant.AudioLevel.MEDIUM
|
||||
),
|
||||
raiseHandAllowed = false,
|
||||
renderInPip = false,
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerRaiseHandPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Bob Smith"),
|
||||
isMicrophoneEnabled = true,
|
||||
audioLevel = CallParticipant.AudioLevel.HIGH,
|
||||
handRaisedTimestamp = System.currentTimeMillis()
|
||||
),
|
||||
raiseHandAllowed = true,
|
||||
renderInPip = false,
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPipPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
|
||||
isMicrophoneEnabled = false
|
||||
),
|
||||
renderInPip = true,
|
||||
modifier = Modifier.size(200.dp, 200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPipLandscapePreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
|
||||
isMicrophoneEnabled = false
|
||||
),
|
||||
renderInPip = true,
|
||||
modifier = Modifier.size(200.dp, 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerBlockedPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Diana Prince", isBlocked = true)
|
||||
),
|
||||
renderInPip = false,
|
||||
onInfoMoreInfoClick = {},
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipNormalPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
|
||||
isMicrophoneEnabled = true,
|
||||
audioLevel = CallParticipant.AudioLevel.MEDIUM
|
||||
),
|
||||
renderInPip = true,
|
||||
selfPipMode = SelfPipMode.NORMAL_SELF_PIP,
|
||||
isMoreThanOneCameraAvailable = true,
|
||||
onSwitchCameraClick = {},
|
||||
modifier = Modifier.size(CallScreenMetrics.NormalRendererDpSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipExpandedPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
|
||||
isMicrophoneEnabled = true,
|
||||
audioLevel = CallParticipant.AudioLevel.HIGH
|
||||
),
|
||||
renderInPip = true,
|
||||
selfPipMode = SelfPipMode.EXPANDED_SELF_PIP,
|
||||
isMoreThanOneCameraAvailable = true,
|
||||
onSwitchCameraClick = {},
|
||||
modifier = Modifier.size(CallScreenMetrics.ExpandedRendererDpSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipMiniPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
|
||||
isMicrophoneEnabled = false
|
||||
),
|
||||
renderInPip = true,
|
||||
selfPipMode = SelfPipMode.MINI_SELF_PIP,
|
||||
isMoreThanOneCameraAvailable = false,
|
||||
modifier = Modifier.size(CallScreenMetrics.SmallRendererDpSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -52,6 +54,8 @@ fun CallParticipantsPager(
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier
|
||||
.displayCutoutPadding()
|
||||
.statusBarsPadding()
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> {
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.BottomSheetScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -55,6 +56,7 @@ import org.signal.core.ui.compose.TriggerAlignedPopupState
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
|
||||
import org.thoughtcrime.securesms.compose.SignalTheme
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId
|
||||
@@ -163,6 +165,7 @@ fun CallScreen(
|
||||
scaffoldState = callScreenController.scaffoldState,
|
||||
sheetDragHandle = null,
|
||||
sheetPeekHeight = peekHeight.dp,
|
||||
sheetContainerColor = SignalTheme.colors.colorSurface1,
|
||||
sheetMaxWidth = 540.dp,
|
||||
sheetContent = {
|
||||
BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
@@ -189,7 +192,9 @@ fun CallScreen(
|
||||
val callInfoAlpha = max(0f, peekPercentage)
|
||||
|
||||
if (callInfoAlpha > 0f) {
|
||||
callInfoView(callInfoAlpha)
|
||||
Surface {
|
||||
callInfoView(callInfoAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
if (callControlsAlpha > 0f) {
|
||||
@@ -348,10 +353,12 @@ private fun Viewport(
|
||||
) {
|
||||
val isEmptyOngoingCall = webRtcCallState.inOngoingCall && callParticipantsPagerState.callParticipants.isEmpty()
|
||||
if (webRtcCallState.isPreJoinOrNetworkUnavailable || isEmptyOngoingCall) {
|
||||
LargeLocalVideoRenderer(
|
||||
localParticipant = localParticipant,
|
||||
modifier = modifier
|
||||
)
|
||||
if (localParticipant.isVideoEnabled) {
|
||||
LargeLocalVideoRenderer(
|
||||
localParticipant = localParticipant,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -446,7 +453,6 @@ private fun LargeLocalVideoRenderer(
|
||||
) {
|
||||
CallParticipantRenderer(
|
||||
callParticipant = localParticipant,
|
||||
isLocalParticipant = true,
|
||||
renderInPip = false,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -33,7 +36,10 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.NightPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -95,19 +101,17 @@ fun CallScreenPreJoinOverlay(
|
||||
if (!isLocalVideoEnabled) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.symbol_video_slash_24
|
||||
),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off),
|
||||
color = Color.White
|
||||
)
|
||||
val isCompactWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
||||
if (isCompactWidth) {
|
||||
YourCameraIsOff(spacedBy = 8.dp)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
YourCameraIsOff()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
@@ -118,13 +122,34 @@ fun CallScreenPreJoinOverlay(
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
CallCameraDirectionToggle(
|
||||
onClick = onCameraToggleClick,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YourCameraIsOff(
|
||||
spacedBy: Dp = 0.dp
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.symbol_video_slash_24
|
||||
),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(bottom = spacedBy)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallCameraDirectionToggle(
|
||||
onClick: () -> Unit,
|
||||
@@ -222,7 +247,7 @@ fun CallScreenTopBarPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
fun CallScreenPreJoinOverlayPreview() {
|
||||
Previews.Preview {
|
||||
@@ -235,7 +260,7 @@ fun CallScreenPreJoinOverlayPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
fun CallScreenPreJoinOverlayWithTogglePreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -47,7 +47,6 @@ import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.NightPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantView
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
|
||||
@@ -95,14 +94,16 @@ fun MoveableLocalVideoRenderer(
|
||||
state.animateTo(targetSize)
|
||||
|
||||
val selfPipMode = when (localRenderState) {
|
||||
WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED -> {
|
||||
CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP
|
||||
WebRtcLocalRenderState.EXPANDED -> {
|
||||
SelfPipMode.EXPANDED_SELF_PIP
|
||||
} WebRtcLocalRenderState.FOCUSED -> {
|
||||
SelfPipMode.FOCUSED_SELF_PIP
|
||||
}
|
||||
WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
|
||||
CallParticipantView.SelfPipMode.MINI_SELF_PIP
|
||||
SelfPipMode.MINI_SELF_PIP
|
||||
}
|
||||
else -> {
|
||||
CallParticipantView.SelfPipMode.NORMAL_SELF_PIP
|
||||
SelfPipMode.NORMAL_SELF_PIP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,6 @@ fun MoveableLocalVideoRenderer(
|
||||
) {
|
||||
CallParticipantRenderer(
|
||||
callParticipant = localParticipant,
|
||||
isLocalParticipant = true,
|
||||
renderInPip = true,
|
||||
selfPipMode = selfPipMode,
|
||||
onToggleCameraDirection = onToggleCameraDirectionClick,
|
||||
|
||||
Reference in New Issue
Block a user