Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/develop' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
daedric7 committed Jan 2, 2025
2 parents 10ad8d4 + 5880bbb commit 37bb5eb
Show file tree
Hide file tree
Showing 34 changed files with 489 additions and 77 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.features.knockrequests.impl.banner

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

/**
* Draw a row of avatars (they must all have the same size), from start to end.
* @param avatarDataList the avatars to render. Note: they will all be rendered, the caller may
* want to limit the list size
* @param modifier Jetpack Compose modifier
* @param overlapRatio the overlap ration. When 0f, avatars will render without overlap, when 1f
* only the first avatar will be visible
*/
@Composable
fun AvatarRow(
avatarDataList: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
overlapRatio: Float = 0.5f,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier,
) {
val lastItemIndex = avatarDataList.size - 1
val avatarSize = avatarDataList.firstOrNull()?.size?.dp ?: return
val avatarSizePx = avatarSize.toPx()
avatarDataList
.reversed()
.forEachIndexed { index, avatarData ->
Avatar(
modifier = Modifier
.padding(start = avatarSize * (1 - overlapRatio) * (lastItemIndex - index))
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left (right in RTL).
drawContent()
val xOffset = if (isRtl) {
size.width - avatarSizePx * (overlapRatio - 0.5f)
} else {
0f + avatarSizePx * (overlapRatio - 0.5f)
}
if (index < lastItemIndex) {
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height / 2,
),
radius = avatarSizePx / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
// Keep internal padding, it has the advantage to not reduce the size of the Avatar image,
// which is already small in our use case.
.padding(2.dp),
avatarData = avatarData,
)
}
}
}

@Composable
@PreviewsDayNight
internal fun AvatarRowPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}

@Composable
@PreviewsDayNight
internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}
}

@Composable
private fun ContentToPreview(overlapRatio: Float) {
AvatarRow(
avatarDataList = listOf("A", "B", "C").map {
AvatarData(
id = it,
name = it,
size = AvatarSize.RoomListItem,
)
}.toImmutableList(),
overlapRatio = overlapRatio,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
Expand All @@ -56,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

private const val MAX_AVATAR_COUNT = 3

Expand Down Expand Up @@ -120,9 +114,9 @@ private fun KnockRequestsBannerContent(
}

Column(
modifier
.fillMaxWidth()
.padding(all = 16.dp)
modifier
.fillMaxWidth()
.padding(all = 16.dp)
) {
Row {
KnockRequestAvatarView(
Expand Down Expand Up @@ -212,44 +206,16 @@ private fun KnockRequestAvatarListView(
knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier,
) {
val avatarSize = AvatarSize.KnockRequestBanner.dp
Box(
val avatars = knockRequests
.take(MAX_AVATAR_COUNT)
.map { knockRequest ->
knockRequest.getAvatarData(AvatarSize.KnockRequestBanner)
}
.toImmutableList()
AvatarRow(
avatarDataList = avatars,
modifier = modifier,
) {
knockRequests
.take(MAX_AVATAR_COUNT)
.reversed()
.let { smallReversedList ->
val lastItemIndex = smallReversedList.size - 1
smallReversedList.forEachIndexed { index, knockRequest ->
Avatar(
modifier = Modifier
.padding(start = avatarSize / 2 * (lastItemIndex - index))
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left.
drawContent()
if (index < lastItemIndex) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = size.height / 2,
),
radius = avatarSize.toPx() / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
.padding(2.dp),
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
)
}
}
}
)
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.features.knockrequests.impl.banner

import androidx.compose.ui.tooling.preview.PreviewParameterProvider

class OverlapRatioProvider : PreviewParameterProvider<Float> {
override val values: Sequence<Float> = sequenceOf(
0f,
0.25f,
0.5f,
0.75f,
1f
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -21,7 +22,9 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toPx
Expand All @@ -45,10 +48,11 @@ fun DmAvatars(
val boxSize = userAvatarData.size.dp * SIZE_RATIO
val boxSizePx = boxSize.toPx()
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier.size(boxSize),
) {
// Draw user avatar and cut top right corner
// Draw user avatar and cut top end corner
Avatar(
avatarData = userAvatarData,
modifier = Modifier
Expand All @@ -58,10 +62,15 @@ fun DmAvatars(
}
.drawWithContent {
drawContent()
val xOffset = if (isRtl) {
size.width - boxSizePx + otherAvatarRadius
} else {
boxSizePx - otherAvatarRadius
}
drawCircle(
color = Color.Black,
center = Offset(
x = boxSizePx - otherAvatarRadius,
x = xOffset,
y = size.height - (boxSizePx - otherAvatarRadius),
),
radius = otherAvatarRadius / 0.9f,
Expand Down Expand Up @@ -106,3 +115,13 @@ internal fun DmAvatarsPreview() = ElementThemedPreview {
openOtherAvatarPreview = {},
)
}

@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsRtlPreview() {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
DmAvatarsPreview()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,49 +12,63 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
import javax.inject.Inject

sealed interface UnifiedPushGatewayResolverResult {
data class Success(val gateway: String) : UnifiedPushGatewayResolverResult
data class Error(val gateway: String) : UnifiedPushGatewayResolverResult
data object NoMatrixGateway : UnifiedPushGatewayResolverResult
data object ErrorInvalidUrl : UnifiedPushGatewayResolverResult
}

interface UnifiedPushGatewayResolver {
suspend fun getGateway(endpoint: String): String
suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult
}

@ContributesBinding(AppScope::class)
class DefaultUnifiedPushGatewayResolver @Inject constructor(
private val unifiedPushApiFactory: UnifiedPushApiFactory,
private val coroutineDispatchers: CoroutineDispatchers,
) : UnifiedPushGatewayResolver {
private val logger = Timber.tag("DefaultUnifiedPushGatewayResolver")

override suspend fun getGateway(endpoint: String): String {
override suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult {
val url = tryOrNull(
onError = { logger.d(it, "Cannot parse endpoint as an URL") }
onError = { Timber.tag("DefaultUnifiedPushGatewayResolver").d(it, "Cannot parse endpoint as an URL") }
) {
URL(endpoint)
}
return if (url == null) {
logger.d("Using default gateway")
UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL
Timber.tag("DefaultUnifiedPushGatewayResolver").d("ErrorInvalidUrl")
UnifiedPushGatewayResolverResult.ErrorInvalidUrl
} else {
val port = if (url.port != -1) ":${url.port}" else ""
val customBase = "${url.protocol}://${url.host}$port"
val customUrl = "$customBase/_matrix/push/v1/notify"
logger.i("Testing $customUrl")
Timber.tag("DefaultUnifiedPushGatewayResolver").i("Testing $customUrl")
return withContext(coroutineDispatchers.io) {
val api = unifiedPushApiFactory.create(customBase)
try {
val discoveryResponse = api.discover()
if (discoveryResponse.unifiedpush.gateway == "matrix") {
logger.d("The endpoint seems to be a valid UnifiedPush gateway")
Timber.tag("DefaultUnifiedPushGatewayResolver").d("The endpoint seems to be a valid UnifiedPush gateway")
UnifiedPushGatewayResolverResult.Success(customUrl)
} else {
logger.e("The endpoint does not seem to be a valid UnifiedPush gateway")
// The endpoint returned a 200 OK but didn't promote an actual matrix gateway, which means it doesn't have any
Timber.tag("DefaultUnifiedPushGatewayResolver").w("The endpoint does not seem to be a valid UnifiedPush gateway, using fallback")
UnifiedPushGatewayResolverResult.NoMatrixGateway
}
} catch (throwable: Throwable) {
logger.e(throwable, "Error checking for UnifiedPush endpoint")
if ((throwable as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
Timber.tag("DefaultUnifiedPushGatewayResolver").i("Checking for UnifiedPush endpoint yielded 404, using fallback")
UnifiedPushGatewayResolverResult.NoMatrixGateway
} else {
Timber.tag("DefaultUnifiedPushGatewayResolver").e(throwable, "Error checking for UnifiedPush endpoint")
UnifiedPushGatewayResolverResult.Error(customUrl)
}
}
// Always return the custom url.
customUrl
}
}
}
Expand Down
Loading

0 comments on commit 37bb5eb

Please sign in to comment.