Merge branch 'develop' into feature/fga/media_viewer_actions

This commit is contained in:
ganfra
2023-06-06 14:35:25 +02:00
408 changed files with 3011 additions and 2886 deletions

View File

@@ -9,7 +9,7 @@ env:
jobs:
record:
name: Record screenshots on branch ${{ inputs.param_branch }}
name: Record screenshots on branch ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
@@ -22,6 +22,11 @@ jobs:
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
run: "./.github/workflows/scripts/recordScreenshots.sh"
env:

View File

@@ -26,27 +26,12 @@ if [[ -z ${GITHUB_REPOSITORY} ]]; then
exit 1
fi
if [[ -z ${GITHUB_REF_NAME} ]]; then
echo "Missing GITHUB_REF_NAME variable"
exit 1
fi
git config user.name "ElementBot"
git config user.email "benoitm+elementbot@element.io"
echo "Git status"
git status
echo "Fetching..."
git fetch --all
echo "Checkout origin/$GITHUB_REF_NAME"
git checkout "origin/$GITHUB_REF_NAME"
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
echo "Committing changes"
git config user.name "ElementBot"
git config user.email "benoitm+elementbot@element.io"
git add -A
git commit -m "Update screenshots"

3
.gitignore vendored
View File

@@ -40,6 +40,7 @@ captures/
.idea/.name
.idea/assetWizardSettings.xml
.idea/compiler.xml
.idea/deploymentTargetDropDown.xml
.idea/gradle.xml
.idea/jarRepositories.xml
.idea/misc.xml
@@ -54,6 +55,7 @@ captures/
.idea/inspectionProfiles
# Shelved changes in the IDE
.idea/shelf
.idea/sonarlint
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
@@ -88,7 +90,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
/.idea/deploymentTargetDropDown.xml
/tmp
.DS_Store

View File

@@ -21,4 +21,6 @@ appId: ${APP_ID}
- inputText: ${PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Help improve ElementX dbg"
timeout: 10_000

View File

@@ -51,13 +51,12 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.features.verifysession.api)
implementation(projects.features.roomdetails.api)
implementation(projects.tests.uitests)
implementation(libs.coil)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil.Coil
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
@@ -39,6 +40,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
@@ -50,6 +52,7 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@@ -57,8 +60,13 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@@ -68,20 +76,43 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomListEntryPoint: RoomListEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val analyticsOptInEntryPoint: AnalyticsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val analyticsService: AnalyticsService,
private val coroutineScope: CoroutineScope,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomList,
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
private fun observeAnalyticsState() {
analyticsService.didAskUserConsent()
.distinctUntilChanged()
.onEach { isConsentAsked ->
if (isConsentAsked) {
switchToRoomList()
} else {
switchToAnalytics()
}
}
.launchIn(lifecycleScope)
}
private fun switchToRoomList() {
backstack.safeRoot(NavTarget.RoomList)
}
private fun switchToAnalytics() {
backstack.safeRoot(NavTarget.AnalyticsSettings)
}
interface Callback : Plugin {
fun onOpenBugReport() = Unit
}
@@ -105,6 +136,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
observeAnalyticsState()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(inputs.matrixClient) }
@@ -128,6 +160,9 @@ class LoggedInFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize
object Permanent : NavTarget
@@ -150,11 +185,15 @@ class LoggedInFlowNode @AssistedInject constructor(
object VerifySession : NavTarget
@Parcelize
object InviteList: NavTarget
object InviteList : NavTarget
@Parcelize
object AnalyticsSettings : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext)
}
@@ -244,6 +283,9 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
NavTarget.AnalyticsSettings -> {
analyticsOptInEntryPoint.createNode(this, buildContext)
}
}
}
@@ -260,6 +302,12 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
private fun splashNode(buildContext: BuildContext) = node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {

View File

@@ -25,8 +25,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test

View File

@@ -221,7 +221,9 @@ koverMerged {
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
}
bound {
minValue = 90

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.analytics.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface AnalyticsEntryPoint : SimpleFeatureEntryPoint

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api
sealed interface AnalyticsOptInEvents {
data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvents
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api.preferences
import io.element.android.libraries.architecture.Presenter
interface AnalyticsPreferencesPresenter : Presenter<AnalyticsPreferencesState>

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api.preferences
import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsPreferencesState(
val applicationName: String,
val isEnabled: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit,
)

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api.preferences
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<AnalyticsPreferencesState> {
override val values: Sequence<AnalyticsPreferencesState>
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
)
}
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "ElementX",
isEnabled = false,
eventSink = {}
)

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.api.preferences
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun AnalyticsPreferencesView(
state: AnalyticsPreferencesState,
modifier: Modifier = Modifier,
) {
fun onEnabledChanged(isEnabled: Boolean) {
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
}
PreferenceCategory(title = stringResource(id = StringR.string.screen_analytics_settings_share_data)) {
val firstPart = stringResource(id = StringR.string.screen_analytics_settings_help_us_improve, state.applicationName)
val secondPart = buildAnnotatedStringWithColoredPart(
StringR.string.screen_analytics_settings_read_terms,
StringR.string.screen_analytics_settings_read_terms_content_link
)
val title = "$firstPart\n\n$secondPart"
PreferenceSwitch(
title = title,
isChecked = state.isEnabled,
onCheckedChange = ::onEnabledChanged
)
}
}
@Composable
fun buildAnnotatedStringWithColoredPart(
@StringRes fullTextRes: Int,
@StringRes coloredTextRes: Int,
color: Color = LinkColor,
underline: Boolean = true,
) = buildAnnotatedString {
val coloredPart = stringResource(coloredTextRes)
val fullText = stringResource(fullTextRes, coloredPart)
val startIndex = fullText.indexOf(coloredPart)
append(fullText)
addStyle(
style = SpanStyle(
color = color,
textDecoration = if (underline) TextDecoration.Underline else null
), start = startIndex, end = startIndex + coloredPart.length
)
}
@Preview
@Composable
fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: AnalyticsPreferencesState) {
AnalyticsPreferencesView(state)
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.analytics.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
api(projects.features.analytics.api)
api(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class AnalyticsOptInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AnalyticsOptInPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AnalyticsOptInView(
state = state,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class AnalyticsOptInPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val analyticsService: AnalyticsService,
) : Presenter<AnalyticsOptInState> {
@Composable
override fun present(): AnalyticsOptInState {
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: AnalyticsOptInEvents) {
when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
}
localCoroutineScope.launch {
analyticsService.setDidAskUserConsent()
}
}
return AnalyticsOptInState(
applicationName = buildMeta.applicationName,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
analyticsService.setUserConsent(enabled)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsOptInState(
val applicationName: String,
val eventSink: (AnalyticsOptInEvents) -> Unit
)

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject
open class AnalyticsOptInStateProvider @Inject constructor(
) : PreviewParameterProvider<AnalyticsOptInState> {
override val values: Sequence<AnalyticsOptInState>
get() = sequenceOf(
aAnalyticsOptInState(),
)
}
fun aAnalyticsOptInState() = AnalyticsOptInState(
applicationName = "ElementX",
eventSink = {}
)

View File

@@ -0,0 +1,238 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import android.graphics.Typeface
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun AnalyticsOptInView(
state: AnalyticsOptInState,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Image(
painterResource(id = R.drawable.element_logo_stars),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_help_us_improve, state.applicationName),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
Text(
text = buildAnnotatedStringWithColoredPart(
R.string.screen_analytics_prompt_read_terms,
R.string.screen_analytics_prompt_read_terms_content_link
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_data_usage).toAnnotatedString(),
color = MaterialTheme.colorScheme.secondary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing).toAnnotatedString(),
color = MaterialTheme.colorScheme.secondary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_settings),
color = MaterialTheme.colorScheme.secondary,
)
}
}
Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_enable))
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_not_now))
}
Spacer(Modifier.height(40.dp))
}
}
}
fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
append(this@toAnnotatedString)
val spannable = SpannableString(this@toAnnotatedString)
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
}
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
}
}
}
@Composable
fun buildAnnotatedStringWithColoredPart(
@StringRes fullTextRes: Int,
@StringRes coloredTextRes: Int,
color: Color = LinkColor,
underline: Boolean = true,
) = buildAnnotatedString {
val coloredPart = stringResource(coloredTextRes)
val fullText = stringResource(fullTextRes, coloredPart)
val startIndex = fullText.indexOf(coloredPart)
append(fullText)
addStyle(
style = SpanStyle(
color = color,
textDecoration = if (underline) TextDecoration.Underline else null
), start = startIndex, end = startIndex + coloredPart.length
)
}
@Preview
@Composable
fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight {
ContentToPreview(state)
}
@Preview
@Composable
fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark {
ContentToPreview(state)
}
@Composable
private fun ContentToPreview(state: AnalyticsOptInState) {
AnalyticsOptInView(state = state)
}

View File

@@ -14,22 +14,19 @@
* limitations under the License.
*/
package io.element.android.services.analytics.noop
package io.element.android.features.analytics.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.services.analytics.api.AnalyticsTracker
import io.element.android.services.analytics.api.VectorAnalyticsEvent
import io.element.android.services.analytics.api.VectorAnalyticsScreen
import io.element.android.services.analytics.api.plan.UserProperties
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class NoopAnalyticsTracker @Inject constructor() : AnalyticsTracker {
override fun capture(event: VectorAnalyticsEvent) = Unit
override fun screen(screen: VectorAnalyticsScreen) = Unit
override fun updateUserProperties(userProperties: UserProperties) = Unit
class DefaultAnalyticsEntryPoint @Inject constructor() : AnalyticsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<AnalyticsOptInNode>(buildContext)
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultAnalyticsPreferencesPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val buildMeta: BuildMeta,
) : AnalyticsPreferencesPresenter {
@Composable
override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope()
val isEnabled = analyticsService.getUserConsent()
.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) {
when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
}
}
return AnalyticsPreferencesState(
applicationName = buildMeta.applicationName,
isEnabled = isEnabled.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
analyticsService.setUserConsent(enabled)
}
}

View File

@@ -0,0 +1,57 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="120dp"
android:height="94dp"
android:viewportWidth="120"
android:viewportHeight="94">
<path
android:pathData="M60.396,4.958L60.604,4.958A44.521,44.521 0,0 1,105.125 49.479L105.125,49.479A44.521,44.521 0,0 1,60.604 94L60.396,94A44.521,44.521 0,0 1,15.875 49.479L15.875,49.479A44.521,44.521 0,0 1,60.396 4.958z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M53.228,26.676C53.228,24.958 54.623,23.566 56.344,23.566C67.82,23.566 77.123,32.847 77.123,44.296C77.123,46.014 75.727,47.406 74.006,47.406C72.285,47.406 70.889,46.014 70.889,44.296C70.889,36.282 64.377,29.785 56.344,29.785C54.623,29.785 53.228,28.393 53.228,26.676Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M67.772,72.282C67.772,73.999 66.377,75.391 64.655,75.391C53.18,75.391 43.877,66.11 43.877,54.661C43.877,52.944 45.272,51.552 46.994,51.552C48.715,51.552 50.111,52.944 50.111,54.661C50.111,62.675 56.623,69.172 64.655,69.172C66.377,69.172 67.772,70.564 67.772,72.282Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M37.644,56.734C35.922,56.734 34.527,55.342 34.527,53.625C34.527,42.176 43.83,32.895 55.305,32.895C57.027,32.895 58.422,34.287 58.422,36.004C58.422,37.722 57.027,39.114 55.305,39.114C47.272,39.114 40.76,45.611 40.76,53.625C40.76,55.342 39.365,56.734 37.644,56.734Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M83.356,42.223C85.078,42.223 86.473,43.615 86.473,45.332C86.473,56.781 77.17,66.063 65.695,66.063C63.973,66.063 62.578,64.671 62.578,62.953C62.578,61.236 63.973,59.844 65.695,59.844C73.728,59.844 80.24,53.347 80.24,45.332C80.24,43.615 81.635,42.223 83.356,42.223Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M39.571,77.305C40.181,77.305 40.683,76.856 40.769,76.227C41.7,69.687 42.587,68.79 48.918,68.076C49.56,68.001 50.041,67.478 50.041,66.87C50.041,66.251 49.57,65.75 48.929,65.664C42.63,64.843 41.817,64.043 40.769,57.502C40.662,56.873 40.181,56.435 39.571,56.435C38.972,56.435 38.47,56.873 38.374,57.513C37.454,64.053 36.566,64.95 30.235,65.664C29.594,65.739 29.123,66.251 29.123,66.87C29.123,67.478 29.583,67.99 30.235,68.076C36.534,68.951 37.315,69.697 38.374,76.238C38.491,76.867 38.983,77.305 39.571,77.305Z"
android:strokeWidth="1.5"
android:fillColor="#ffffff"
android:strokeColor="#0DBD8B"/>
<path
android:strokeWidth="1"
android:pathData="M82.194,35.392C82.697,35.392 83.111,35.019 83.182,34.494C83.949,29.044 84.682,28.297 89.905,27.701C90.434,27.639 90.831,27.203 90.831,26.697C90.831,26.181 90.443,25.763 89.913,25.692C84.717,25.007 84.046,24.34 83.182,18.89C83.093,18.365 82.697,18.001 82.194,18.001C81.7,18.001 81.285,18.365 81.205,18.899C80.447,24.349 79.714,25.096 74.491,25.692C73.962,25.754 73.574,26.181 73.574,26.697C73.574,27.203 73.953,27.63 74.491,27.701C79.688,28.43 80.332,29.053 81.205,34.503C81.302,35.028 81.708,35.392 82.194,35.392Z"
android:fillColor="#ffffff"
android:strokeColor="#0DBD8B"/>
<path
android:pathData="M113.846,18.87C114.174,18.87 114.444,18.631 114.49,18.296C114.991,14.807 115.468,14.329 118.873,13.948C119.218,13.908 119.477,13.63 119.477,13.305C119.477,12.975 119.224,12.708 118.879,12.662C115.491,12.224 115.054,11.797 114.49,8.309C114.433,7.973 114.174,7.74 113.846,7.74C113.524,7.74 113.254,7.973 113.202,8.315C112.707,11.803 112.23,12.281 108.825,12.662C108.48,12.702 108.227,12.975 108.227,13.305C108.227,13.63 108.474,13.903 108.825,13.948C112.213,14.415 112.633,14.813 113.202,18.301C113.265,18.637 113.53,18.87 113.846,18.87Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M107.169,9.131C107.354,9.131 107.506,8.997 107.531,8.808C107.813,6.846 108.081,6.577 109.997,6.363C110.191,6.34 110.336,6.183 110.336,6.001C110.336,5.815 110.194,5.665 110,5.639C108.094,5.393 107.849,5.153 107.531,3.191C107.499,3.002 107.354,2.871 107.169,2.871C106.988,2.871 106.836,3.002 106.807,3.194C106.529,5.156 106.26,5.425 104.345,5.639C104.151,5.662 104.008,5.815 104.008,6.001C104.008,6.183 104.147,6.337 104.345,6.363C106.25,6.625 106.486,6.849 106.807,8.811C106.842,9 106.991,9.131 107.169,9.131Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M108.575,24.435C108.8,24.435 108.986,24.271 109.018,24.04C109.362,21.642 109.69,21.314 112.031,21.052C112.268,21.024 112.446,20.833 112.446,20.61C112.446,20.383 112.272,20.199 112.035,20.167C109.706,19.866 109.405,19.573 109.018,17.175C108.978,16.944 108.8,16.783 108.575,16.783C108.353,16.783 108.167,16.944 108.132,17.179C107.792,19.577 107.464,19.905 105.123,20.167C104.885,20.195 104.711,20.383 104.711,20.61C104.711,20.833 104.881,21.02 105.123,21.052C107.452,21.372 107.74,21.646 108.132,24.044C108.175,24.275 108.357,24.435 108.575,24.435Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M6.197,15.392C6.504,15.392 6.758,15.168 6.801,14.853C7.27,11.583 7.718,11.135 10.91,10.778C11.233,10.74 11.476,10.479 11.476,10.175C11.476,9.865 11.239,9.615 10.915,9.572C7.739,9.161 7.329,8.761 6.801,5.491C6.747,5.176 6.504,4.958 6.197,4.958C5.895,4.958 5.642,5.176 5.593,5.496C5.129,8.767 4.682,9.215 1.49,9.572C1.166,9.609 0.929,9.865 0.929,10.175C0.929,10.479 1.161,10.735 1.49,10.778C4.666,11.215 5.059,11.589 5.593,14.859C5.652,15.174 5.9,15.392 6.197,15.392Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M13.231,5.653C13.375,5.653 13.493,5.549 13.513,5.402C13.732,3.876 13.941,3.667 15.431,3.5C15.582,3.482 15.695,3.36 15.695,3.218C15.695,3.074 15.584,2.957 15.433,2.937C13.951,2.745 13.76,2.559 13.513,1.033C13.488,0.886 13.375,0.784 13.231,0.784C13.09,0.784 12.972,0.886 12.95,1.035C12.733,2.561 12.524,2.77 11.035,2.937C10.884,2.955 10.773,3.074 10.773,3.218C10.773,3.36 10.881,3.48 11.035,3.5C12.517,3.704 12.7,3.878 12.95,5.404C12.977,5.551 13.093,5.653 13.231,5.653Z"
android:strokeAlpha="0.4"
android:fillColor="#0DBD8B"
android:fillAlpha="0.4"/>
<path
android:pathData="M16.747,11.914C16.89,11.914 17.009,11.809 17.029,11.663C17.248,10.136 17.457,9.927 18.946,9.761C19.097,9.743 19.21,9.621 19.21,9.479C19.21,9.335 19.1,9.218 18.949,9.198C17.467,9.006 17.275,8.819 17.029,7.293C17.004,7.147 16.89,7.044 16.747,7.044C16.606,7.044 16.488,7.147 16.465,7.296C16.249,8.822 16.04,9.031 14.55,9.198C14.399,9.215 14.289,9.335 14.289,9.479C14.289,9.621 14.397,9.741 14.55,9.761C16.032,9.965 16.216,10.139 16.465,11.665C16.493,11.812 16.609,11.914 16.747,11.914Z"
android:strokeAlpha="0.4"
android:fillColor="#0DBD8B"
android:fillAlpha="0.4"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string>
<string name="screen_analytics_prompt_help_us_improve">"Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string>
<string name="screen_analytics_prompt_title">"Helfen Sie %1$s zu verbessern"</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage"><b>"Nu"</b>" înregistrăm sau profilăm datele contului"</string>
<string name="screen_analytics_prompt_help_us_improve">"Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime."</string>
<string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aici"</string>
<string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string>
<string name="screen_analytics_prompt_third_party_sharing"><b>"Nu"</b>" împărtășim informații cu terți"</string>
<string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"We "<b>"don\'t"</b>" record or profile any account data"</string>
<string name="screen_analytics_prompt_help_us_improve">"Help us identify issues and improve %1$s by sharing anonymous usage data."</string>
<string name="screen_analytics_prompt_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"here"</string>
<string name="screen_analytics_prompt_settings">"You can turn this off anytime in settings"</string>
<string name="screen_analytics_prompt_third_party_sharing">"We "<b>"don\'t"</b>" share information with third parties"</string>
<string name="screen_analytics_prompt_title">"Help improve %1$s"</string>
</resources>

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.impl.preferences.DefaultAnalyticsPreferencesPresenter
import io.element.android.features.analytics.test.A_BUILD_META
import io.element.android.features.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AnalyticsOptInPresenterTest {
@Test
fun `present - enable`() = runTest {
val analyticsService = FakeAnalyticsService(isEnabled = false)
val presenter = AnalyticsOptInPresenter(
A_BUILD_META,
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isTrue()
}
}
@Test
fun `present - not now`() = runTest {
val analyticsService = FakeAnalyticsService(isEnabled = false)
val presenter = AnalyticsOptInPresenter(
A_BUILD_META,
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isFalse()
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.impl.preferences
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.test.A_BUILD_META
import io.element.android.features.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AnalyticsPreferencesPresenterTest {
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
A_BUILD_META
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
}
}
@Test
fun `present - initial state not available`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(
FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
A_BUILD_META
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isEnabled).isFalse()
}
}
@Test
fun `present - enable and disable`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
A_BUILD_META
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
assertThat(awaitItem().isEnabled).isTrue()
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.analytics.test"
}
dependencies {
implementation(projects.services.analytics.api)
implementation(projects.libraries.core)
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.test
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAnalyticsService(
isEnabled: Boolean = false,
didAskUserConsent: Boolean = false
): AnalyticsService {
private var isEnabledFlow = MutableStateFlow(isEnabled)
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow
override suspend fun setUserConsent(userConsent: Boolean) {
isEnabledFlow.value = userConsent
}
override fun didAskUserConsent(): Flow<Boolean> = didAskUserConsentFlow
override suspend fun setDidAskUserConsent() {
didAskUserConsentFlow.value = true
}
override fun getAnalyticsId(): Flow<String> = MutableStateFlow("")
override suspend fun setAnalyticsId(analyticsId: String) {
}
override suspend fun onSignOut() {
}
override fun capture(event: VectorAnalyticsEvent) {
}
override fun screen(screen: VectorAnalyticsScreen) {
}
override fun updateUserProperties(userProperties: UserProperties) {
}
override fun trackError(throwable: Throwable) {
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.analytics.test
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
val A_BUILD_META = BuildMeta(
isDebuggable = true,
buildType = BuildType.DEBUG,
applicationName = "Element X test",
applicationId = "",
lowPrivacyLoggingEnabled = false,
versionName = "",
gitRevision = "",
gitRevisionDate = "",
gitBranchName = "",
flavorDescription = "",
flavorShortDescription = "",
)

View File

@@ -16,15 +16,26 @@
package io.element.android.features.createroom.impl.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -49,6 +60,8 @@ fun SearchUserBar(
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
val columnState = rememberLazyListState()
SearchBar(
query = query,
onQueryChange = onTextChanged,
@@ -59,19 +72,38 @@ fun SearchUserBar(
showBackButton = showBackButton,
contentPrefix = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
// We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour
// should change to indicate elevation.
val elevation = remember {
derivedStateOf {
if (columnState.canScrollBackward) {
4.dp
} else {
0.dp
}
}
}
val appBarContainerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation.value),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
)
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
modifier = Modifier.background(appBarContainerColor)
)
}
},
resultState = state,
resultHandler = { users ->
LazyColumn {
LazyColumn(state = columnState) {
if (isMultiSelectionEnabled) {
items(users) { searchResult ->
itemsIndexed(users) { index, searchResult ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
searchResult = searchResult,
@@ -84,14 +116,20 @@ fun SearchUserBar(
}
}
)
if (index < users.lastIndex) {
Divider()
}
}
} else {
items(users) { searchResult ->
itemsIndexed(users) { index, searchResult ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
searchResult = searchResult,
onClick = { onUserSelected(searchResult.matrixUser) }
)
if (index < users.lastIndex) {
Divider()
}
}
}
}

View File

@@ -130,6 +130,7 @@ fun ConfigureRoomView(
)
if (state.config.invites.isNotEmpty()) {
SelectedUsersList(
modifier = Modifier.padding(bottom = 16.dp),
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {

View File

@@ -26,14 +26,15 @@ import androidx.compose.foundation.layout.consumeWindowInsets
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.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -142,7 +143,11 @@ fun CreateRoomRootViewTopBar(
},
actions = {
IconButton(onClick = onClosePressed) {
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(id = StringR.string.action_close))
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_close),
tint = MaterialTheme.colorScheme.primary,
)
}
}
)
@@ -157,7 +162,7 @@ fun CreateRoomActionButtonsList(
Column(modifier = modifier) {
CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_groups,
text = stringResource(id = StringR.string.action_create_a_room),
text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked,
)
CreateRoomActionButton(
@@ -185,11 +190,16 @@ fun CreateRoomActionButton(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.alpha(0.5f), // FIXME align on Design system theme (removing alpha should be fine)
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = iconRes,
contentDescription = null,
)
Text(text = text)
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
}
}

View File

@@ -1,17 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nová místnost"</string>
<string name="screen_create_room_action_invite_people">"Pozvat lidi"</string>
<string name="screen_create_room_add_people_title">"Přidat lidi"</string>
<string name="screen_create_room_action_invite_people">"Pozvat přátele do Elementu"</string>
<string name="screen_create_room_add_people_title">"Pozvat lidi"</string>
<string name="screen_create_room_error_creating_room">"Při vytváření místnosti došlo k chybě"</string>
<string name="screen_create_room_private_option_description">"Zprávy v této místnosti jsou šifrované. Šifrování nelze později vypnout."</string>
<string name="screen_create_room_private_option_title">"Soukromá místnost (jen pro pozvané)"</string>
<string name="screen_create_room_public_option_description">"Zprávy nejsou šifrované a může si je přečíst kdokoli. Šifrování můžete povolit později."</string>
<string name="screen_create_room_public_option_title">"Veřejná místnost (kdokoli)"</string>
<string name="screen_create_room_room_name_label">"Název místnosti"</string>
<string name="screen_create_room_room_name_placeholder">"např. Produktový sprint"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_create_room_topic_placeholder">"O čem je tato místnost?"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
<string name="screen_create_room_title">"Vytvořit místnost"</string>
</resources>
</resources>

View File

@@ -1,17 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Personen einladen"</string>
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
<string name="screen_create_room_action_invite_people">"Freunde zu Element einladen"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Nachrichten sind nicht verschlüsselt und jeder kann sie lesen. Du kannst die Verschlüsselung zu einem späteren Zeitpunkt aktivieren."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (jeder)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_room_name_placeholder">"z.B. Produkt-Sprint"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Worum geht es in diesem Raum?"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_title">"Raum erstellen"</string>
</resources>
</resources>

View File

@@ -5,4 +5,4 @@
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
<string name="screen_create_room_title">"Crear una sala"</string>
</resources>
</resources>

View File

@@ -9,9 +9,7 @@
<string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string>
<string name="screen_create_room_public_option_title">"Salle publique (nimporte qui)"</string>
<string name="screen_create_room_room_name_label">"Nom de la salle"</string>
<string name="screen_create_room_room_name_placeholder">"Ex: Sprint Produit"</string>
<string name="screen_create_room_topic_label">"Sujet (optionnel)"</string>
<string name="screen_create_room_topic_placeholder">"De quoi parle cette salle ?"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur s\'est produite lors de la tentative de démarrage d\'une discussion"</string>
<string name="screen_create_room_title">"Créer une salle"</string>
</resources>
</resources>

View File

@@ -5,4 +5,4 @@
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
<string name="screen_create_room_title">"Crea una stanza"</string>
</resources>
</resources>

View File

@@ -9,9 +9,7 @@
<string name="screen_create_room_public_option_description">"Mesajele nu sunt criptate și oricine le poate citi. Puteți activa criptarea la o dată ulterioară."</string>
<string name="screen_create_room_public_option_title">"Cameră publică (oricine)"</string>
<string name="screen_create_room_room_name_label">"Numele camerei"</string>
<string name="screen_create_room_room_name_placeholder">"e.g. Mici și Cozonaci"</string>
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
<string name="screen_create_room_topic_placeholder">"Despre ce este această cameră?"</string>
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_create_room_title">"Creați o cameră"</string>
</resources>
</resources>

View File

@@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -42,6 +42,7 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -81,6 +82,9 @@ fun InviteListView(
ConfirmationDialog(
content = stringResource(contentResource, state.declineConfirmationDialog.name),
title = stringResource(titleResource),
submitText = stringResource(StringR.string.action_decline),
cancelText = stringResource(StringR.string.action_cancel),
emphasizeSubmitButton = true,
onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) }
)
@@ -143,14 +147,18 @@ fun InviteListContent(
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(
itemsIndexed(
items = state.inviteList,
) { invite ->
) { index, invite ->
InviteSummaryRow(
invite = invite,
onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
)
if (index != state.inviteList.lastIndex) {
Divider()
}
}
}
}

View File

@@ -17,13 +17,13 @@
package io.element.android.features.invitelist.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -31,23 +31,18 @@ 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.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -64,8 +59,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import kotlinx.collections.immutable.persistentMapOf
import io.element.android.libraries.ui.strings.R as StringR
private val minHeight = 72.dp
@@ -104,23 +99,27 @@ internal fun DefaultInviteSummaryRow(
verticalAlignment = Alignment.Top
) {
Avatar(
invite.roomAvatarData,
invite.roomAvatarData.copy(size = AvatarSize.Custom(52.dp)),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp)
.padding(start = 16.dp, end = 4.dp)
.alignByBaseline()
.weight(1f)
) {
val bonusPadding = if (invite.isNew) 12.dp else 0.dp
// Name
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
text = invite.roomName,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
style = noFontPadding,
modifier = Modifier.padding(end = bonusPadding),
)
// ID or Alias
@@ -131,7 +130,8 @@ internal fun DefaultInviteSummaryRow(
text = it,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = bonusPadding),
)
}
@@ -145,8 +145,8 @@ internal fun DefaultInviteSummaryRow(
OutlinedButton(
content = { Text(stringResource(StringR.string.action_decline), style = ElementTextStyles.Button) },
onClick = onDeclineClicked,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 7.dp),
modifier = Modifier.weight(1f).heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
)
Spacer(modifier = Modifier.width(12.dp))
@@ -154,8 +154,8 @@ internal fun DefaultInviteSummaryRow(
Button(
content = { Text(stringResource(StringR.string.action_accept), style = ElementTextStyles.Button) },
onClick = onAcceptClicked,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 7.dp),
modifier = Modifier.weight(1f).heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
)
}
}
@@ -173,45 +173,36 @@ internal fun DefaultInviteSummaryRow(
@Composable
private fun SenderRow(sender: InviteSender) {
Text(
text = buildAnnotatedString {
val placeholder = "$"
val text = stringResource(R.string.screen_invites_invited_you, placeholder)
val nameIndex = text.indexOf(placeholder)
// Text before the placeholder
append(text.take(nameIndex))
// Avatar and display name
appendInlineContent("avatar")
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)) {
append(sender.displayName)
}
// Text after the placeholder
append(text.drop(nameIndex + placeholder.length))
},
color = MaterialTheme.colorScheme.secondary,
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 6.dp),
inlineContent = persistentMapOf(
"avatar" to InlineTextContent(
with(LocalDensity.current) {
Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center)
}
) {
Box(
Modifier
.fillMaxHeight()
.padding(end = 4.dp)
) {
Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
modifier = Modifier.align(Alignment.Center)
)
}
}
) {
Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
)
)
Text(
text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->
val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
AnnotatedString(
text = text,
spanStyles = listOf(
AnnotatedString.Range(
SpanStyle(
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
),
start = senderNameStart,
end = senderNameStart + sender.displayName.length
)
)
)
},
style = noFontPadding,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
)
}
}
@Preview

View File

@@ -24,7 +24,8 @@ open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteList
override val values: Sequence<InviteListInviteSummary>
get() = sequenceOf(
aInviteListInviteSummary(),
aInviteListInviteSummary().copy(roomAlias = "#someroom:example.com"),
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"),
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true),
aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
aInviteListInviteSummary().copy(isNew = true)
)
@@ -32,9 +33,9 @@ open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteList
fun aInviteListInviteSummary() = InviteListInviteSummary(
roomId = RoomId("!room1:example.com"),
roomName = "Some room",
roomName = "Some room with a long name that will truncate",
sender = InviteSender(
userId = UserId("@alice:example.org"),
displayName = "Alice"
userId = UserId("@alice-with-a-long-mxid:example.org"),
displayName = "Alice with a long name"
),
)

View File

@@ -5,4 +5,5 @@
<string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout chat s %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odmítnout chat"</string>
<string name="screen_invites_empty_list">"Žádné pozvánky"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string>
</resources>

View File

@@ -5,5 +5,4 @@
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s hat dich eingeladen"</string>
</resources>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s te invitó."</string>
</resources>

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_empty_list">"Aucune invitation"</string>
<string name="screen_invites_invited_you">"%1$s vous a invité."</string>
</resources>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s ti ha invitato"</string>
</resources>
<string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string>
</resources>

View File

@@ -5,5 +5,5 @@
<string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Refuzați conversația"</string>
<string name="screen_invites_empty_list">"Nicio invitație"</string>
<string name="screen_invites_invited_you">"%1$s v-a invitat"</string>
</resources>
<string name="screen_invites_invited_you">"%1$s (%2$s) v-a invitat."</string>
</resources>

View File

@@ -27,15 +27,10 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -83,7 +78,7 @@ class LeaveRoomPresenterImpl @Inject constructor(
}
}
private suspend fun showLeaveRoomAlert(
private fun showLeaveRoomAlert(
matrixClient: MatrixClient,
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
@@ -91,7 +86,7 @@ private suspend fun showLeaveRoomAlert(
matrixClient.getRoom(roomId)?.use { room ->
confirmation.value = when {
!room.isPublic -> PrivateRoom(roomId)
(room.memberCount() as? Async.Success<Int>)?.state == 1 -> LastUserInRoom(roomId)
room.joinedMemberCount == 1L -> LastUserInRoom(roomId)
else -> Generic(roomId)
}
}
@@ -116,12 +111,3 @@ private suspend fun MatrixClient.leaveRoom(
}
progress.value = LeaveRoomState.Progress.Hidden
}
private suspend fun MatrixRoom.memberCount(): Async<Int> = membersStateFlow.first().let { membersState ->
when (membersState) {
MatrixRoomMembersState.Unknown -> Async.Uninitialized
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
}
}

View File

@@ -24,11 +24,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -100,24 +96,7 @@ class LeaveRoomPresenterImplTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
RoomMember(
userId = UserId(value = "@aUserId:aDomain"),
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false
)
)
)
)
},
result = FakeMatrixRoom(joinedMemberCount = 1),
)
}
)

View File

@@ -16,7 +16,6 @@
package io.element.android.features.login.impl.oidc.webview
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceRequest
@@ -26,8 +25,6 @@ import android.webkit.WebViewClient
class OidcWebViewClient(
private val eventListener: WebViewEventListener,
) : WebViewClient() {
// We will revert to API 23, in the mean time ignore the warning here.
@SuppressLint("ObsoleteSdkInt")
@TargetApi(Build.VERSION_CODES.N)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return shouldOverrideUrl(request.url.toString())

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Tento server v současné době nepodporuje klouzavou synchronizaci."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string>
<string name="screen_change_server_form_notice">"Můžete se připojit pouze k serveru, který podporuje klouzavou synchronizaci. Správce vašeho domovského serveru jej bude muset nakonfigurovat. %1$s"</string>
<string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string>
<string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
@@ -10,6 +12,7 @@
<string name="screen_login_form_header">"Zadejte své údaje"</string>
<string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
<string name="screen_change_server_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>

View File

@@ -12,6 +12,7 @@
<string name="screen_login_form_header">"Gib deine Daten ein"</string>
<string name="screen_login_server_header">"Wo deine Gespräche leben"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
<string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string>

View File

@@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d změna místnosti"</item>
<item quantity="few">"%1$d změny místnosti"</item>
<item quantity="other">"%1$d změn místnosti"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
<string name="screen_room_attachment_source_files">"Příloha"</string>
<string name="screen_room_attachment_source_gallery">"Knihovna fotografií a videí"</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
</resources>

View File

@@ -9,5 +9,6 @@
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
</resources>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Ruční přihlášení"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Přihlásit se pomocí QR kódu"</string>
<string name="screen_onboarding_sign_up">"Vytvořit účet"</string>
<string name="screen_onboarding_subtitle">"Komunikujte a spolupracujte bezpečně"</string>
<string name="screen_onboarding_welcome_subtitle">"Vítejte v %1$s Beta. Vylepšený, pro rychlost a jednoduchost."</string>
<string name="screen_onboarding_welcome_title">"Buďte ve svém živlu"</string>
</resources>

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
<string name="screen_onboarding_sign_up">"Konto erstellen"</string>
<string name="screen_onboarding_welcome_subtitle">"Willkommen zur %1$s Beta. Verbessert, für Geschwindigkeit und Einfachheit."</string>
<string name="screen_onboarding_welcome_title">"Sei in deinem Element"</string>
</resources>

View File

@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api)
implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api)
implementation(libs.datetime)
@@ -59,6 +60,8 @@ dependencies {
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.impl)
testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.runtime.Composable
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.libraries.architecture.Async
@@ -27,6 +28,7 @@ import javax.inject.Inject
class PreferencesRootPresenter @Inject constructor(
private val logoutPresenter: LogoutPreferencePresenter,
private val rageshakePresenter: RageshakePreferencesPresenter,
private val analyticsPresenter: AnalyticsPreferencesPresenter,
private val buildType: BuildType,
) : Presenter<PreferencesRootState> {
@@ -34,10 +36,12 @@ class PreferencesRootPresenter @Inject constructor(
override fun present(): PreferencesRootState {
val logoutState = logoutPresenter.present()
val rageshakeState = rageshakePresenter.present()
val analyticsState = analyticsPresenter.present()
val showDeveloperSettings = buildType != BuildType.RELEASE
return PreferencesRootState(
logoutState = logoutState,
rageshakeState = rageshakeState,
analyticsState = analyticsState,
myUser = Async.Uninitialized,
showDeveloperSettings = showDeveloperSettings
)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.Async
@@ -24,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState(
val logoutState: LogoutPreferenceState,
val rageshakeState: RageshakePreferencesState,
val analyticsState: AnalyticsPreferencesState,
val myUser: Async<MatrixUser>,
val showDeveloperSettings: Boolean
)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.analytics.api.preferences.aAnalyticsPreferencesState
import io.element.android.features.logout.api.aLogoutPreferenceState
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.libraries.architecture.Async
@@ -23,6 +24,7 @@ import io.element.android.libraries.architecture.Async
fun aPreferencesRootState() = PreferencesRootState(
logoutState = aLogoutPreferenceState(),
rageshakeState = aRageshakePreferencesState(),
analyticsState = aAnalyticsPreferencesState(),
myUser = Async.Uninitialized,
showDeveloperSettings = true
)

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.logout.api.LogoutPreferenceView
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesView
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
@@ -52,6 +53,9 @@ fun PreferencesRootView(
title = stringResource(id = StringR.string.common_settings)
) {
UserPreferences(state.myUser)
AnalyticsPreferencesView(
state = state.analyticsState,
)
RageshakePreferencesView(
state = state.rageshakeState,
onOpenRageshake = onOpenRageShake,

View File

@@ -20,12 +20,14 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.impl.preferences.DefaultAnalyticsPreferencesPresenter
import io.element.android.features.analytics.test.A_BUILD_META
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -35,10 +37,12 @@ class PreferencesRootPresenterTest {
fun `present - initial state`() = runTest {
val logoutPresenter = DefaultLogoutPreferencePresenter(FakeMatrixClient())
val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore())
val analyticsPresenter = DefaultAnalyticsPreferencesPresenter(FakeAnalyticsService(), A_BUILD_META)
val presenter = PreferencesRootPresenter(
logoutPresenter,
rageshakePresenter,
BuildType.DEBUG
analyticsPresenter,
A_BUILD_META.buildType
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -46,6 +50,7 @@ class PreferencesRootPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.logoutState.logoutAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.analyticsState.isEnabled).isFalse()
assertThat(initialState.rageshakeState.isEnabled).isTrue()
assertThat(initialState.rageshakeState.isSupported).isTrue()
assertThat(initialState.rageshakeState.sensitivity).isEqualTo(1.0f)

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string>
<string name="rageshake_detection_dialog_content">"Zdá se, že jste frustrovaně třásli telefonem. Chcete otevřít obrazovku pro nahlášení chyby?"</string>
</resources>

View File

@@ -1,4 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Připojit snímek obrazovky"</string>
<string name="screen_bug_report_contact_me">"V případě dalších dotazů se na mě můžete obrátit"</string>
<string name="screen_bug_report_edit_screenshot">"Upravit snímek obrazovky"</string>
<string name="screen_bug_report_editor_description">"Popište prosím chybu. Co jste udělali? Co jste očekávali, že se stane? Co se ve skutečnosti stalo? Uveďte co nejvíce podrobností."</string>
<string name="screen_bug_report_editor_placeholder">"Popište chybu…"</string>
<string name="screen_bug_report_editor_supporting">"Pokud je to možné, prosím, napište popis anglicky."</string>
<string name="screen_bug_report_include_crash_logs">"Odeslat záznamy o selhání"</string>
<string name="screen_bug_report_include_logs">"Odeslat protokoly pro pomoc"</string>
<string name="screen_bug_report_include_screenshot">"Odeslat snímek obrazovky"</string>
<string name="screen_bug_report_logs_description">"Aby bylo možné zkontrolovat, zda věci fungují podle očekávání, budou s vaší zprávou odeslány protokoly. Tyto budou soukromé. Chcete-li pouze odeslat zprávu, vypněte toto nastavení."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string>
</resources>

View File

@@ -27,12 +27,10 @@ import androidx.compose.runtime.remember
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import javax.inject.Inject
@@ -51,7 +49,6 @@ class RoomDetailsPresenter @Inject constructor(
}
val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState)
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendStateEvent(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendStateEvent(membersState, StateEventType.ROOM_AVATAR)
@@ -85,7 +82,7 @@ class RoomDetailsPresenter @Inject constructor(
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = topicState,
memberCount = memberCount,
memberCount = room.joinedMemberCount,
isEncrypted = room.isEncrypted,
canInvite = canInvite,
canEdit = canEditAvatar || canEditName || canEditTopic,
@@ -131,18 +128,4 @@ class RoomDetailsPresenter @Inject constructor(
}
return canSendEvent
}
@Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
return remember(membersState) {
derivedStateOf {
when (membersState) {
MatrixRoomMembersState.Unknown -> Async.Uninitialized
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
}
}
}
}
}

View File

@@ -18,7 +18,6 @@ package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomMember
data class RoomDetailsState(
@@ -27,7 +26,7 @@ data class RoomDetailsState(
val roomAlias: String?,
val roomAvatarUrl: String?,
val roomTopic: RoomTopicState,
val memberCount: Async<Int>,
val memberCount: Long,
val isEncrypted: Boolean,
val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,

View File

@@ -19,7 +19,6 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@@ -32,7 +31,6 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true),
@@ -73,7 +71,7 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing..."
),
memberCount = Async.Success(32),
memberCount = 32,
isEncrypted = true,
canInvite = false,
canEdit = false,

View File

@@ -59,7 +59,6 @@ import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -145,10 +144,8 @@ fun RoomDetailsView(
}
if (state.roomType is RoomDetailsType.Room) {
val memberCount = state.memberCount.dataOrNull()
MembersSection(
memberCount = memberCount,
isLoading = state.memberCount.isLoading(),
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
@@ -273,8 +270,7 @@ internal fun TopicSection(
@Composable
internal fun MembersSection(
memberCount: Int?,
isLoading: Boolean,
memberCount: Long,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -282,9 +278,8 @@ internal fun MembersSection(
PreferenceText(
title = stringResource(R.string.screen_room_details_people_title),
icon = Icons.Outlined.Person,
currentValue = memberCount?.toString(),
currentValue = memberCount.toString(),
onClick = openRoomMemberList,
loadingCurrentValue = isLoading,
)
}
}

View File

@@ -26,7 +26,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEd
get() = sequenceOf(
aRoomDetailsEditState(),
aRoomDetailsEditState().copy(roomTopic = ""),
aRoomDetailsEditState().copy(roomAvatarUrl = Uri.EMPTY),
aRoomDetailsEditState().copy(roomAvatarUrl = Uri.parse("example://uri")),
aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState().copy(saveAction = Async.Loading()),

View File

@@ -145,7 +145,7 @@ fun RoomDetailsEditView(
LabelledTextField(
label = stringResource(id = R.string.screen_room_details_room_name_label),
value = state.roomName,
placeholder = stringResource(id = StringR.string.common_room_name_placeholder),
placeholder = stringResource(StringR.string.common_room_name_placeholder),
singleLine = true,
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) },
)
@@ -160,9 +160,9 @@ fun RoomDetailsEditView(
if (state.canChangeTopic) {
LabelledTextField(
label = stringResource(id = StringR.string.common_topic),
label = stringResource(StringR.string.common_topic),
value = state.roomTopic,
placeholder = stringResource(id = StringR.string.common_topic_placeholder),
placeholder = stringResource(StringR.string.common_topic_placeholder),
maxLines = 10,
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) },
)

View File

@@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -40,6 +40,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -181,7 +182,7 @@ private fun RoomInviteMembersSearchBar(
)
LazyColumn {
items(results) { invitableUser ->
itemsIndexed(results) { index, invitableUser ->
if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) {
CheckableUnresolvedUserRow(
checked = invitableUser.isSelected,
@@ -208,6 +209,10 @@ private fun RoomInviteMembersSearchBar(
modifier = Modifier.fillMaxWidth()
)
}
if (index < results.lastIndex) {
Divider()
}
}
}
},

View File

@@ -164,6 +164,7 @@ private fun LazyListScope.roomMemberListSection(
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = headerText(),
fontSize = 16.sp,
style = ElementTextStyles.Regular.callout,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,

View File

@@ -5,17 +5,25 @@
<item quantity="few">"%1$d osoby"</item>
<item quantity="other">"%1$d osob"</item>
</plurals>
<string name="screen_room_details_add_topic_title">"Přidat téma"</string>
<string name="screen_room_details_already_a_member">"Již členem"</string>
<string name="screen_room_details_already_invited">"Již pozván(a)"</string>
<string name="screen_room_details_edit_room_title">"Upravit místnost"</string>
<string name="screen_room_details_edition_error">"Nepodařilo se nám aktualizovat všechny informace o této místnosti."</string>
<string name="screen_room_details_edition_error_title">"Nelze aktualizovat místnost"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Zprávy jsou zabezpečeny zámky. Pouze vy a příjemci máte jedinečné klíče k jejich odemčení."</string>
<string name="screen_room_details_encryption_enabled_title">"Šifrování zpráv povoleno"</string>
<string name="screen_room_details_room_name_label">"Název místnosti"</string>
<string name="screen_room_details_share_room_title">"Sdílet místnost"</string>
<string name="screen_room_details_updating_room">"Aktualizace místnosti…"</string>
<string name="screen_room_member_list_pending_header_title">"Nevyřízeno"</string>
<string name="screen_room_member_list_room_members_header_title">"Členové místnosti"</string>
<string name="screen_dm_details_block_alert_action">"Zablokovat"</string>
<string name="screen_dm_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny zprávy od nich budou skryty. Tuto akci můžete kdykoli vrátit zpět."</string>
<string name="screen_dm_details_block_user">"Zablokovat uživatele"</string>
<string name="screen_dm_details_unblock_alert_action">"Odblokovat"</string>
<string name="screen_dm_details_unblock_alert_description">"Po odblokování uživatele budete moci opět zobrazit všechny jeho zprávy."</string>
<string name="screen_dm_details_unblock_user">"Odblokovat uživatele"</string>
<string name="screen_room_details_invite_people_title">"Pozvat lidi"</string>
<string name="screen_room_details_leave_room_title">"Opustit místnost"</string>
<string name="screen_room_details_people_title">"Lidé"</string>
<string name="screen_room_details_security_title">"Zabezpečení"</string>

View File

@@ -4,17 +4,21 @@
<item quantity="one">"1 Person"</item>
<item quantity="other">"%1$d Personen"</item>
</plurals>
<string name="screen_room_details_add_topic_title">"Thema hinzufügen"</string>
<string name="screen_room_details_already_a_member">"Bereits Mitglied"</string>
<string name="screen_room_details_already_invited">"Bereits eingeladen"</string>
<string name="screen_room_details_edit_room_title">"Raum bearbeiten"</string>
<string name="screen_room_details_edition_error">"Wir konnten nicht alle Informationen für diesen Raum aktualisieren."</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."</string>
<string name="screen_room_details_encryption_enabled_title">"Nachrichtenverschlüsselung aktiviert"</string>
<string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_room_member_list_room_members_header_title">"Raummitglieder"</string>
<string name="screen_dm_details_block_alert_action">"Blockieren"</string>
<string name="screen_dm_details_block_alert_description">"Blockierte Benutzer können dir keine Nachrichten senden und alle Nachrichten von ihnen werden ausgeblendet. Du kannst diese Aktion jederzeit rückgängig machen."</string>
<string name="screen_dm_details_block_user">"Nutzer blockieren"</string>
<string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_dm_details_unblock_alert_description">"Wenn du den Benutzer entsperrst, kannst du wieder alle Nachrichten von ihm sehen."</string>
<string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string>
<string name="screen_room_details_invite_people_title">"Personen einladen"</string>
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_people_title">"Personen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string>

View File

@@ -6,6 +6,7 @@
</plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."</string>
<string name="screen_room_details_encryption_enabled_title">"Cifrado de mensajes activado"</string>
<string name="screen_room_details_invite_people_title">"Invitar a otras personas"</string>
<string name="screen_room_details_share_room_title">"Compartir sala"</string>
<string name="screen_dm_details_block_alert_action">"Bloquear"</string>
<string name="screen_dm_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."</string>
@@ -13,7 +14,6 @@
<string name="screen_dm_details_unblock_alert_action">"Desbloquear"</string>
<string name="screen_dm_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string>
<string name="screen_dm_details_unblock_user">"Desbloquear usuario"</string>
<string name="screen_room_details_invite_people_title">"Invitar gente"</string>
<string name="screen_room_details_leave_room_title">"Salir de la sala"</string>
<string name="screen_room_details_people_title">"Personas"</string>
<string name="screen_room_details_security_title">"Seguridad"</string>

View File

@@ -13,7 +13,6 @@
<string name="screen_dm_details_unblock_alert_action">"Débloquer"</string>
<string name="screen_dm_details_unblock_alert_description">"Lorsque vous débloquez l\'utilisateur, vous pourrez à nouveau voir tous leur messages."</string>
<string name="screen_dm_details_unblock_user">"Débloquer l\'utilisateur"</string>
<string name="screen_room_details_invite_people_title">"Inviter des personnes"</string>
<string name="screen_room_details_leave_room_title">"Quitter la salle"</string>
<string name="screen_room_details_people_title">"Personnes"</string>
<string name="screen_room_details_security_title">"Sécurité"</string>

View File

@@ -6,6 +6,7 @@
</plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."</string>
<string name="screen_room_details_encryption_enabled_title">"Crittografia messaggi abilitata"</string>
<string name="screen_room_details_invite_people_title">"Invita persone"</string>
<string name="screen_room_details_share_room_title">"Condividi stanza"</string>
<string name="screen_dm_details_block_alert_action">"Blocca"</string>
<string name="screen_dm_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string>
@@ -13,7 +14,6 @@
<string name="screen_dm_details_unblock_alert_action">"Sblocca"</string>
<string name="screen_dm_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string>
<string name="screen_dm_details_unblock_user">"Sblocca utente"</string>
<string name="screen_room_details_invite_people_title">"Invita persone"</string>
<string name="screen_room_details_leave_room_title">"Esci dalla stanza"</string>
<string name="screen_room_details_people_title">"Persone"</string>
<string name="screen_room_details_security_title">"Sicurezza"</string>

View File

@@ -10,6 +10,7 @@
<string name="screen_room_details_edition_error">"A apărut o eroare la actualizarea detaliilor camerei"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
<string name="screen_room_details_encryption_enabled_title">"Criptarea mesajelor este activată"</string>
<string name="screen_room_details_invite_people_title">"Invitați persoane"</string>
<string name="screen_room_details_share_room_title">"Partajați camera"</string>
<string name="screen_room_details_updating_room">"Se actualizează camera…"</string>
<string name="screen_room_member_list_pending_header_title">"În așteptare"</string>
@@ -20,7 +21,6 @@
<string name="screen_dm_details_unblock_alert_action">"Deblocați"</string>
<string name="screen_dm_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_dm_details_unblock_user">"Deblocați utilizatorul"</string>
<string name="screen_room_details_invite_people_title">"Invitați persoane"</string>
<string name="screen_room_details_leave_room_title">"Părăsiți camera"</string>
<string name="screen_room_details_people_title">"Persoane"</string>
<string name="screen_room_details_security_title">"Securitate"</string>

View File

@@ -26,18 +26,15 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@@ -69,48 +66,13 @@ class RoomDetailsPresenterTests {
assertThat(initialState.roomName).isEqualTo(room.name)
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount)
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val error = RuntimeException()
val room = aMatrixRoom()
val roomMembers = listOf(
aRoomMember(A_USER_ID),
aRoomMember(A_USER_ID_2, membership = RoomMembershipState.INVITE),
)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem()
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
skipItems(1)
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
val loadingState = awaitItem()
assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
skipItems(1)
val failureState = awaitItem()
assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
skipItems(1)
val successState = awaitItem()
assertThat(successState.memberCount).isEqualTo(Async.Success(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)

View File

@@ -29,10 +29,12 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -47,16 +49,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomlist.impl.components.RoomListTopBar
@@ -68,12 +73,13 @@ import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
@@ -212,37 +218,48 @@ fun RoomListContent(
if (state.invitesState != InvitesState.NoInvites) {
item {
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) {
TextButton(
content = {
Text(stringResource(StringR.string.action_invites_list))
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
},
onClick = onInvitesClicked,
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.clickable(role = Role.Button, onClick = onInvitesClicked)
.heightIn(min = 48.dp),
) {
Text(
text = stringResource(StringR.string.action_invites_list),
fontSize = 14.sp,
style = noFontPadding,
)
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
Spacer(Modifier.width(16.dp))
}
}
}
items(
itemsIndexed(
items = state.roomList,
contentType = { room -> room.contentType() },
) { room ->
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != state.roomList.lastIndex) {
Divider()
}
}
}
}

View File

@@ -36,14 +36,15 @@ test_core = "1.5.0"
#other
coil = "2.4.0"
datetime = "0.4.0"
serialization_json = "1.5.0"
serialization_json = "1.5.1"
showkase = "1.0.0-beta18"
jsoup = "1.16.1"
appyx = "1.2.0"
dependencycheck = "8.2.1"
dependencyanalysis = "1.20.0"
stem = "2.3.0"
sqldelight = "1.5.5"
telephoto = "0.3.0"
telephoto = "0.4.0"
# DI
dagger = "2.46.1"
@@ -51,7 +52,7 @@ anvil = "2.4.6"
# quality
detekt = "1.23.0"
dependencygraph = "0.10"
dependencygraph = "0.12"
[libraries]
# Project
@@ -148,6 +149,11 @@ otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
sentry_android = "io.sentry:sentry-android:6.21.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
# Di
inject = "javax.inject:javax.inject:1"
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
@@ -178,6 +184,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = "org.jlleitschuh.gradle.ktlint:11.3.2"
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }
paparazzi = "app.cash.paparazzi:1.2.0"
sonarqube = "org.sonarqube:4.2.0.3129"
kover = "org.jetbrains.kotlinx.kover:0.6.1"

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.designsystem
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -25,12 +26,14 @@ import androidx.compose.ui.unit.sp
// TODO Remove
object ElementTextStyles {
@Suppress("DEPRECATION")
val Button = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
lineHeight = 22.sp,
fontStyle = FontStyle.Normal,
textAlign = TextAlign.Center,
platformStyle = PlatformTextStyle(includeFontPadding = false)
)
object Bold {

View File

@@ -19,7 +19,8 @@ package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -46,29 +47,10 @@ fun ProgressDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!text.isNullOrBlank()) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
}
}
}
ProgressDialogContent(
modifier = modifier,
text = text,
)
}
}
@@ -80,22 +62,23 @@ private fun ProgressDialogContent(
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 38.dp, bottom = 32.dp, start = 40.dp, end = 40.dp)
) {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!text.isNullOrBlank()) {
Spacer(modifier = Modifier.height(22.dp))
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
}
}

View File

@@ -49,7 +49,7 @@ fun Avatar(
val commonModifier = modifier
.size(avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url == null) {
if (avatarData.url.isNullOrBlank()) {
InitialsAvatar(
avatarData = avatarData,
modifier = commonModifier,
@@ -72,7 +72,7 @@ private fun ImageAvatar(
AsyncImage(
model = avatarData,
onError = {
Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable)
Timber.e(it.result.throwable, "Error loading avatar $it\n${it.result}")
},
contentDescription = contentDescription,
contentScale = ContentScale.Crop,

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.designsystem.theme
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -107,3 +108,13 @@ val titleMediumDefault: TextStyle = TextStyle(
letterSpacing = 0.5.sp
)
// Temporary style for text that needs to be aligned without weird font padding issues. `includeFontPadding` will default to false in a future version of
// compose, at which point this can be removed.
//
// Ref: https://medium.com/androiddevelopers/fixing-font-padding-in-compose-text-768cd232425b
@Suppress("DEPRECATION")
val noFontPadding: TextStyle = TextStyle(
platformStyle = PlatformTextStyle(
includeFontPadding = false
)
)

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(avatar byl také změněn)"</string>
<string name="state_event_avatar_url_changed">"%1$s změnil(a) svůj profilový obrázek"</string>
<string name="state_event_avatar_url_changed_by_you">"Změnili jste svůj profilový obrázek"</string>
<string name="state_event_display_name_changed_from">"%1$s změnil(a) své zobrazované jméno z %2$s na %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Změnili jste své zobrazované jméno z %1$s na %2$s"</string>
<string name="state_event_display_name_removed">"%1$s odstranil(a) své zobrazované jméno (%2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Odstranili jste své zobrazované jméno (%1$s)"</string>
<string name="state_event_display_name_set">"%1$s nastavil(a) své zobrazované jméno na %2$s"</string>
<string name="state_event_display_name_set_by_you">"Změnili jste své zobrazované jméno na %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s změnil(a) obrázek místnosti"</string>
<string name="state_event_room_avatar_changed_by_you">"Změnili jste obrázek místnosti"</string>
<string name="state_event_room_avatar_removed">"%1$s odstranili obrázek místnosti"</string>
<string name="state_event_room_avatar_removed_by_you">"Odstranili jste obrázek místnosti"</string>
<string name="state_event_room_ban">"%1$s vykázal(a) %2$s"</string>
<string name="state_event_room_ban_by_you">"Vykázali jste %1$s"</string>
<string name="state_event_room_created">"%1$s založil(a) místnost"</string>
<string name="state_event_room_created_by_you">"Založili jste místnost"</string>
<string name="state_event_room_invite">"%1$s pozval(a) %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s přijal(a) pozvání"</string>
<string name="state_event_room_invite_accepted_by_you">"Přijali jste pozvání"</string>
<string name="state_event_room_invite_by_you">"Pozvali jste %1$s"</string>
<string name="state_event_room_invite_you">"Pozvali jste %1$s"</string>
<string name="state_event_room_join">"%1$s vstoupil(a) do místnosti"</string>
<string name="state_event_room_join_by_you">"Vstoupili jste do místnosti"</string>
<string name="state_event_room_knock">"%1$s požádal(a) o vstup"</string>
<string name="state_event_room_knock_accepted">"%1$s povolil(a) vstoupit %2$s"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s vám povolil(a) vstoupit"</string>
<string name="state_event_room_knock_by_you">"Požádali jste o vstup"</string>
<string name="state_event_room_knock_denied">"%1$s zamítl(a) žádost %2$s o vstup"</string>
<string name="state_event_room_knock_denied_by_you">"Zamítli jste žádost %1$s o vstup"</string>
<string name="state_event_room_knock_denied_you">"%1$s zamítl(a) vaši žádost o vstup"</string>
<string name="state_event_room_knock_retracted">"%1$s již nemá zájem vstoupit"</string>
<string name="state_event_room_knock_retracted_by_you">"Zrušili jste svou žádost vstoupit"</string>
<string name="state_event_room_leave">"%1$s opustil(a) místnost"</string>
<string name="state_event_room_leave_by_you">"Opustili jste místnost"</string>
<string name="state_event_room_name_changed">"%1$s změnil(a) název místnosti na: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Změnili jste název místnosti na: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s odstranil(a) název místnosti"</string>
<string name="state_event_room_name_removed_by_you">"Odstranili jste název místnosti"</string>
<string name="state_event_room_reject">"%1$s pozvánku odmítl(a)"</string>
<string name="state_event_room_reject_by_you">"Odmítli jste pozvání"</string>
<string name="state_event_room_remove">"%1$s odebral(a) %2$s"</string>
<string name="state_event_room_remove_by_you">"Odebrali jste %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s do této místnosti pozval(a) %2$s"</string>
<string name="state_event_room_third_party_invite_by_you">"Poslali jste %1$s pozvání do místnosti"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s zrušil(a) pozvánku do místnosti pro %2$s"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Zrušili jste pozvánku do místnosti pro %1$s"</string>
<string name="state_event_room_topic_changed">"%1$s změnil(a) téma na: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Změnili jste téma na: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s odstranil(a) téma místnosti"</string>
<string name="state_event_room_topic_removed_by_you">"Odstranili jste téma místnosti"</string>
<string name="state_event_room_unban">"%1$s zrušil(a) vykázání %2$s"</string>
<string name="state_event_room_unban_by_you">"Zrušili jste vykázání pro %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s provedl(a) neznámou změnu svého členství"</string>
</resources>

View File

@@ -43,6 +43,7 @@ interface MatrixRoom : Closeable {
val isEncrypted: Boolean
val isDirect: Boolean
val isPublic: Boolean
val joinedMemberCount: Long
/**
* The current loaded members as a StateFlow.

View File

@@ -137,6 +137,9 @@ class RustMatrixRoom(
override val isDirect: Boolean
get() = innerRoom.isDirect()
override val joinedMemberCount: Long
get() = innerRoom.joinedMembersCount().toLong()
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()

View File

@@ -51,6 +51,7 @@ class FakeMatrixRoom(
override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true,
override val isDirect: Boolean = false,
override val joinedMemberCount: Long = 123L,
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {

View File

@@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -35,10 +34,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -62,6 +63,7 @@ fun AvatarActionBottomSheet(
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
displayHandle = true,
sheetContent = {
AvatarActionBottomSheetContent(
actions = actions,
@@ -91,6 +93,7 @@ private fun AvatarActionBottomSheetContent(
headlineContent = {
Text(
text = stringResource(action.titleResId),
fontSize = 16.sp,
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
},
@@ -98,7 +101,7 @@ private fun AvatarActionBottomSheetContent(
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary,
)
}
)

View File

@@ -20,12 +20,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.ElementPreviewDark
@@ -79,8 +81,10 @@ fun CheckableUserRow(
)
Checkbox(
modifier = Modifier
.padding(end = 16.dp),
checked = checked,
onCheckedChange = onCheckedChange,
onCheckedChange = null,
enabled = enabled,
)
}

View File

@@ -16,9 +16,11 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -38,6 +40,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@@ -46,7 +49,7 @@ import io.element.android.libraries.matrix.ui.model.getBestName
fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM,
avatarSize: AvatarSize = AvatarSize.Custom(36.dp),
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
@@ -71,25 +74,29 @@ fun UserRow(
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
.padding(start = 12.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
// Name
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Normal,
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
style = noFontPadding,
)
// Id
subtext?.let {
Text(
text = subtext,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
style = noFontPadding,
)
}
}

View File

@@ -16,16 +16,20 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -38,7 +42,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -51,7 +55,9 @@ fun SelectedUser(
modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit = {},
) {
Box(modifier = modifier.width(56.dp)) {
Box(modifier = modifier
.width(56.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -63,18 +69,23 @@ fun SelectedUser(
style = MaterialTheme.typography.bodyLarge,
)
}
IconButton(
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(20.dp)
.align(Alignment.TopEnd),
onClick = { onUserRemoved(matrixUser) }
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onUserRemoved(matrixUser) }
),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
}
}

View File

@@ -16,18 +16,27 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -35,6 +44,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.math.floor
@Composable
fun SelectedUsersList(
@@ -56,16 +66,64 @@ fun SelectedUsersList(
}
}
val rowWidth by remember {
derivedStateOf {
lazyListState.layoutInfo.viewportSize.width - lazyListState.layoutInfo.beforeContentPadding
}
}
// Calculate spacing to show between each user. This is at least [minimumSpacing], and will grow to ensure that if the available space is filled with
// users, the last visible user will be precisely half visible. This gives an obvious affordance that there are more entries and the list can be scrolled.
// For efficiency, we assume that all the children are the same width. If they needed to be different sizes we'd have to do this calculation each time
// they needed to be measured.
val minimumSpacing = with(LocalDensity.current) { 24.dp.toPx() }
val userWidth = with(LocalDensity.current) { 56.dp.toPx() }
val userSpacing by remember {
derivedStateOf {
if (rowWidth == 0) {
// The row hasn't yet been measured yet, so we don't know how big it is
minimumSpacing
} else {
val userWidthWithSpacing = userWidth + minimumSpacing
val maxVisibleUsers = rowWidth / userWidthWithSpacing
// Round down the number of visible users to end with a state where one is half visible
val targetFraction = (userWidth / 2) / userWidthWithSpacing
val targetUsers = floor(maxVisibleUsers - targetFraction) + targetFraction
// Work out how much extra spacing we need to reduce the number of users that much, then split it evenly amongst the visible users
val extraSpacing = (maxVisibleUsers - targetUsers) * userWidthWithSpacing
val extraSpacingPerUser = extraSpacing / floor(targetUsers)
minimumSpacing + extraSpacingPerUser
}
}
}
LazyRow(
state = lazyListState,
modifier = modifier,
modifier = modifier
.fillMaxWidth(),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(selectedUsers.toList()) { matrixUser ->
SelectedUser(
matrixUser = matrixUser,
onUserRemoved = onUserRemoved,
itemsIndexed(selectedUsers.toList()) { index, matrixUser ->
Layout(
content = {
SelectedUser(
matrixUser = matrixUser,
onUserRemoved = onUserRemoved,
)
},
measurePolicy = { measurables, constraints ->
val placeable = measurables.first().measure(constraints)
val spacing = if (index == selectedUsers.lastIndex) 0f else userSpacing
layout(
width = (placeable.width + spacing).toInt(),
height = placeable.height
) {
placeable.place(0, 0)
}
}
)
}
}
@@ -81,7 +139,23 @@ internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPrev
@Composable
private fun ContentToPreview() {
SelectedUsersList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Two users that will be visible with no scrolling
SelectedUsersList(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
modifier = Modifier
.width(200.dp)
.border(1.dp, Color.Red)
)
// Multiple users that don't fit, so will be spaced out per the measure policy
for (i in 0..5) {
SelectedUsersList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
modifier = Modifier
.width((200 + (i * 20)).dp)
.border(1.dp, Color.Red)
)
}
}
}

View File

@@ -17,9 +17,11 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -39,10 +41,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.R
@@ -62,7 +66,9 @@ fun UnresolvedUserRow(
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
.padding(start = 12.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
// ID
Text(
@@ -72,10 +78,11 @@ fun UnresolvedUserRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
style = noFontPadding,
)
// Warning
Row(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) {
Icon(
imageVector = Icons.Filled.Error,
contentDescription = "",
@@ -121,8 +128,9 @@ fun CheckableUnresolvedUserRow(
)
Checkbox(
modifier = Modifier.padding(end = 16.dp),
checked = checked,
onCheckedChange = onCheckedChange,
onCheckedChange = null,
enabled = enabled,
)
}
@@ -142,9 +150,9 @@ internal fun CheckableUnresolvedUserRowPreview() =
ElementThemedPreview {
val matrixUser = aMatrixUser()
Column {
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value)
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value, enabled = false)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), matrixUser.userId.value, enabled = false)
}
}

View File

@@ -55,7 +55,7 @@ fun MatrixRoom.getDirectRoomMember(): State<RoomMember?> {
@Composable
fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
return remember(roomMembersState) {
derivedStateOf {
if (roomMembers == null) {
null

Some files were not shown because too many files have changed in this diff Show More