Merge branch 'release/0.4.7' into main

This commit is contained in:
Benoit Marty
2024-03-26 16:45:03 +01:00
416 changed files with 1822 additions and 701 deletions

View File

@@ -1,34 +0,0 @@
name: Clear Gradle Cache
on:
workflow_dispatch:
schedule:
# Every nights at 4
- cron: "0 4 * * *"
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx8g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --warn
jobs:
tests:
name: Clear Gradle cache
runs-on: ubuntu-latest
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
- name: ☕️ Use JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-home-cache-cleanup: true
# This should build the project and run the tests, and the build files will be used to diff with the cache
- name: ⚙️ Build the GPlay debug variant, run unit tests
run: ./gradlew :app:assembleGplayDebug test $CI_GRADLE_ARG_PROPERTIES

View File

@@ -33,7 +33,7 @@ jobs:
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: 📈 Generate kover report and verify coverage
run: ./gradlew :app:koverHtmlReport :app:koverXmlReport :app:koverVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyGplayDebug $CI_GRADLE_ARG_PROPERTIES
- name: ✅ Upload kover report
if: always()

View File

@@ -63,7 +63,7 @@ jobs:
with:
name: linting-report
path: |
*/build/reports/**/*.*
**/build/reports/**/*.*
- name: Prepare Danger
if: always()
run: |

View File

@@ -48,14 +48,14 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Run unit tests for debug variant
run: ./gradlew testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: 📈Generate kover report and verify coverage
run: ./gradlew :app:koverHtmlReport :app:koverXmlReport :app:koverVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyGplayDebug $CI_GRADLE_ARG_PROPERTIES
- name: 🚫 Upload kover failed coverage reports
if: failure()
@@ -63,7 +63,7 @@ jobs:
with:
name: kover-error-report
path: |
app/build/reports/kover/verify.err
app/build/reports/kover/verifyGplayDebug.err
- name: ✅ Upload kover report (disabled)
if: always()
@@ -85,5 +85,5 @@ jobs:
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
# with:
# files: build/reports/kover/xml/report.xml
files: app/build/reports/kover/reportGplayDebug.xml
verbose: true

View File

@@ -23,8 +23,6 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
- tapOn: "Continue"
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -1,6 +0,0 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible:
id: "welcome_screen-title"
timeout: 10000

View File

@@ -1,3 +1,23 @@
Changes in Element X v0.4.7 (2024-03-26)
========================================
Features ✨
----------
- Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603))
- Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
- Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521))
Bugfixes 🐛
----------
- Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488))
- Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590))
Other changes
-------------
- Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574))
- Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584))
Changes in Element X v0.4.6 (2024-03-15)
========================================

View File

@@ -24,7 +24,6 @@ import extension.gitBranchName
import extension.gitRevision
import extension.koverDependencies
import extension.setupKover
import org.jetbrains.kotlin.cli.common.toBooleanLenient
plugins {
id("io.element.android-compose-application")
@@ -189,7 +188,7 @@ androidComponents {
val abiCode = abiVersionCodes[name] ?: 0
// Assigns the new version code to output.versionCode, which changes the version code
// for only the output APK, not for the variant itself.
output.versionCode.set((output.versionCode.get() ?: 0) * 10 + abiCode)
output.versionCode.set((output.versionCode.orNull ?: 0) * 10 + abiCode)
}
}
}
@@ -215,26 +214,6 @@ knit {
}
}
val ciBuildProperty = "ci-build"
val isCiBuild = if (project.hasProperty(ciBuildProperty)) {
val raw = project.property(ciBuildProperty) as? String
raw?.toBooleanLenient() == true || raw?.toIntOrNull() == 1
} else {
false
}
kover {
// When running on the CI, run only debug test variants
if (isCiBuild) {
excludeTests {
// Disable instrumentation for debug test tasks
tasks(
"testDebugUnitTest",
)
}
}
}
dependencies {
allLibrariesImpl()
allServicesImpl()

View File

@@ -172,6 +172,7 @@ allprojects {
// Register quality check tasks.
tasks.register("runQualityChecks") {
dependsOn(":tests:konsist:testDebugUnitTest")
project.subprojects {
// For some reason `findByName("lint")` doesn't work
tasks.findByPath("$path:lint")?.let { dependsOn(it) }

View File

@@ -0,0 +1,2 @@
Main changes in this version: Enable the feature "RoomList filters" and "Mark as unread".
Full changelog: https://github.com/element-hq/element-x-android/releases

View File

@@ -36,7 +36,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@@ -73,9 +72,6 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object Placeholder : NavTarget
@Parcelize
data object WelcomeScreen : NavTarget
@Parcelize
data object NotificationsOptIn : NavTarget
@@ -110,15 +106,6 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
ftueState.setWelcomeScreenShown()
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
@@ -146,9 +133,6 @@ class FtueFlowNode @AssistedInject constructor(
private fun moveToNextStep() {
when (ftueState.getNextStep()) {
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn)
}

View File

@@ -21,7 +21,6 @@ import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.permissions.api.PermissionStateProvider
@@ -40,14 +39,12 @@ class DefaultFtueState @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
override suspend fun reset() {
welcomeScreenState.reset()
analyticsService.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -62,12 +59,7 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (shouldDisplayWelcomeScreen()) {
FtueStep.WelcomeScreen
} else {
getNextStep(FtueStep.WelcomeScreen)
}
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) {
null -> if (shouldAskNotificationPermissions()) {
FtueStep.NotificationsOptIn
} else {
getNextStep(FtueStep.NotificationsOptIn)
@@ -87,7 +79,6 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
{ shouldDisplayWelcomeScreen() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() },
@@ -99,10 +90,6 @@ class DefaultFtueState @Inject constructor(
return runBlocking { analyticsService.didAskUserConsent().first().not() }
}
private fun shouldDisplayWelcomeScreen(): Boolean {
return welcomeScreenState.isWelcomeScreenNeeded()
}
private fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
@@ -120,11 +107,6 @@ class DefaultFtueState @Inject constructor(
}
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
shouldDisplayFlow.value = isAnyStepIncomplete()
@@ -132,7 +114,6 @@ class DefaultFtueState @Inject constructor(
}
sealed interface FtueStep {
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
data object LockscreenSetup : FtueStep

View File

@@ -20,7 +20,6 @@ import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
@@ -47,7 +46,6 @@ class DefaultFtueStateTests {
@Test
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
@@ -55,13 +53,11 @@ class DefaultFtueStateTests {
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
@@ -75,7 +71,6 @@ class DefaultFtueStateTests {
@Test
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
@@ -83,26 +78,21 @@ class DefaultFtueStateTests {
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
val steps = mutableListOf<FtueStep?>()
// First step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Second step, notifications opt in
// Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Third step, entering PIN code
// Entering PIN code
steps.add(state.getNextStep(steps.lastOrNull()))
lockScreenService.setIsPinSetup(true)
// Fourth step, analytics opt in
// Analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@@ -110,7 +100,6 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn,
@@ -135,15 +124,14 @@ class DefaultFtueStateTests {
lockScreenService = lockScreenService,
)
// Skip first 3 steps
state.setWelcomeScreenShown()
// Skip first 2 steps
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
assertThat(state.getNextStep(null)).isNull()
// Cleanup
coroutineScope.cancel()
@@ -162,14 +150,12 @@ class DefaultFtueStateTests {
lockScreenService = lockScreenService,
)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
assertThat(state.getNextStep(null)).isNull()
// Cleanup
coroutineScope.cancel()
@@ -177,7 +163,6 @@ class DefaultFtueStateTests {
private fun createState(
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
@@ -187,7 +172,6 @@ class DefaultFtueStateTests {
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Apakah Anda yakin ingin keluar dari percakapan ini? Percakapan ini tidak umum dan Anda tidak akan dapat bergabung lagi tanpa undangan."</string>
<string name="leave_room_alert_empty_subtitle">"Apakah Anda yakin ingin meninggalkan ruangan ini? Anda adalah orang satu-satunya di sini. Jika Anda pergi, tidak akan ada yang bisa bergabung di masa depan, termasuk Anda."</string>
<string name="leave_room_alert_private_subtitle">"Apakah Anda yakin ingin meninggalkan ruangan ini? Ruangan ini tidak umum dan Anda tidak akan dapat bergabung kembali tanpa undangan."</string>
<string name="leave_room_alert_subtitle">"Apakah Anda yakin ingin meninggalkan ruangan?"</string>

View File

@@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api)

View File

@@ -14,6 +14,8 @@
<string name="screen_change_account_provider_subtitle">"Gunakan penyedia akun yang berbeda, seperti server pribadi Anda sendiri atau akun kerja."</string>
<string name="screen_change_account_provider_title">"Ubah penyedia akun"</string>
<string name="screen_change_server_error_invalid_homeserver">"Kami tidak dapat menjangkau server ini. Periksa apakah Anda telah memasukkan URL homeserver dengan benar. Jika URL sudah benar, hubungi administrator homeserver Anda untuk bantuan lebih lanjut."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync tidak tersedia karena adanya masalah dalam berkas .well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Server ini saat ini tidak mendukung sinkronisasi geser."</string>
<string name="screen_change_server_form_header">"URL Homeserver"</string>
<string name="screen_change_server_form_notice">"Anda hanya dapat terhubung ke server yang ada yang mendukung sinkronisasi geser. Admin homeserver Anda perlu mengaturnya. %1$s"</string>
@@ -22,6 +24,7 @@
<string name="screen_login_error_deactivated_account">"Akun ini telah dinonaktifkan."</string>
<string name="screen_login_error_invalid_credentials">"Nama pengguna dan/atau kata sandi salah"</string>
<string name="screen_login_error_invalid_user_id">"Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Server ini diatur untuk menggunakan token penyegaran. Ini tidak didukung ketika menggunakan log masuk berbasis kata sandi."</string>
<string name="screen_login_error_unsupported_authentication">"Homeserver yang dipilih tidak mendukung log masuk kata sandi atau OIDC. Silakan hubungi admin Anda atau pilih homeserver yang lain."</string>
<string name="screen_login_form_header">"Masukkan detail Anda"</string>
<string name="screen_login_subtitle">"Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi."</string>

View File

@@ -295,8 +295,6 @@ class MessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
inviteProgress.value = AsyncData.Loading()
runCatching {
room.updateMembers()
val memberList = when (val memberState = room.membersStateFlow.value) {
is MatrixRoomMembersState.Ready -> memberState.roomMembers
is MatrixRoomMembersState.Error -> memberState.prevRoomMembers.orEmpty()

View File

@@ -463,7 +463,12 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow()
mediaSender.sendMedia(
uri = uri,
mimeType = mimeType,
compressIfPossible = false,
progressCallback = progressCallback
).getOrThrow()
}
.onSuccess {
attachmentState.value = AttachmentsState.None

View File

@@ -615,9 +615,9 @@ private fun MessageEventBubbleContent(
}
val timestampPosition = when (event.content) {
is TimelineItemImageContent,
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
@@ -723,10 +723,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
@Composable
internal fun TimelineItemEventRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
isMine = isMine,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
@@ -736,7 +736,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
isMine = isMine,
content = aTimelineItemImageContent().copy(
aspectRatio = 2.5f
),

View File

@@ -101,7 +101,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
),
aMessageContent(
body = "Video",
type = VideoMessageType("Video", MediaSource("url"), null),
type = VideoMessageType("Video", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Audio",
@@ -113,7 +113,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
),
aMessageContent(
body = "Image",
type = ImageMessageType("Image", MediaSource("url"), null),
type = ImageMessageType("Image", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Sticker",

View File

@@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private const val MIN_HEIGHT_IN_DP = 100
private const val MAX_HEIGHT_IN_DP = 360
const val MIN_HEIGHT_IN_DP = 100
const val MAX_HEIGHT_IN_DP = 360
private const val DEFAULT_ASPECT_RATIO = 1.33f
@Composable

View File

@@ -77,6 +77,7 @@ fun TimelineItemEventContentView(
)
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
@@ -85,6 +86,7 @@ fun TimelineItemEventContentView(
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemFileContent -> TimelineItemFileView(

View File

@@ -16,39 +16,134 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio,
Column(
modifier = modifier.semantics { contentDescription = description },
) {
BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurhash,
)
val containerModifier = if (content.showCaption) {
Modifier
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
} else {
Modifier
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
TimelineItemImageView(content)
TimelineItemImageView(content, {})
}
@PreviewsDayNight
@Composable
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent().copy(
filename = "image.jpg",
body = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
}

View File

@@ -26,7 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData

View File

@@ -16,54 +16,124 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio,
modifier = modifier.semantics { contentDescription = description },
contentAlignment = Alignment.Center,
Column(
modifier = modifier.semantics { contentDescription = description }
) {
BlurHashAsyncImage(
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurHash,
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier.roundedBackground(),
val containerModifier = if (content.showCaption) {
Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp))
} else {
Modifier
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
contentAlignment = Alignment.Center,
) {
Image(
Icons.Default.PlayArrow,
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
Icons.Default.PlayArrow,
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
) {
EditorStyledText(
modifier = Modifier
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
)
}
}
}
}
@@ -71,5 +141,25 @@ fun TimelineItemVideoView(
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
TimelineItemVideoView(content)
TimelineItemVideoView(content, {})
}
@PreviewsDayNight
@Composable
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
body = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
}

View File

@@ -83,6 +83,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
body = messageType.body.trimEnd(),
formatted = messageType.formatted,
filename = messageType.filename,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -91,7 +93,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty()
)
}
is StickerMessageType -> {
@@ -132,6 +134,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
body = messageType.body.trimEnd(),
formatted = messageType.formatted,
filename = messageType.filename,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -141,7 +145,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty(),
)
}
is AudioMessageType -> {

View File

@@ -18,9 +18,12 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
data class TimelineItemImageContent(
val body: String,
val formatted: FormattedBody?,
val filename: String?,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
@@ -33,6 +36,9 @@ data class TimelineItemImageContent(
) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent"
val showCaption = filename != null && filename != body
val caption = if (showCaption) body else ""
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
mediaSource
} else {

View File

@@ -32,6 +32,8 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemImageContent() = TimelineItemImageContent(
body = "a body",
formatted = null,
filename = null,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,

View File

@@ -17,10 +17,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import kotlin.time.Duration
data class TimelineItemVideoContent(
val body: String,
val formatted: FormattedBody?,
val filename: String?,
val duration: Duration,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -33,4 +36,7 @@ data class TimelineItemVideoContent(
val fileExtension: String,
) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent"
val showCaption = filename != null && filename != body
val caption = if (showCaption) body else ""
}

View File

@@ -33,6 +33,8 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4",
formatted = null,
filename = null,
thumbnailSource = null,
blurHash = A_BLUR_HASH,
aspectRatio = 0.5f,

View File

@@ -40,4 +40,11 @@
<plurals name="screen_room_timeline_state_changes">
<item quantity="other">"%1$d perubahan ruangan"</item>
</plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="other">"%1$s, %2$s, dan %3$d lainnya"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="other">"%1$s sedang mengetik"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s dan %2$s"</string>
</resources>

View File

@@ -20,6 +20,7 @@
<string name="screen_room_attachment_text_formatting">"格式化文字"</string>
<string name="screen_room_invite_again_alert_message">"您想要邀請他們回來嗎?"</string>
<string name="screen_room_invite_again_alert_title">"此聊天室只有您一個人"</string>
<string name="screen_room_mentions_at_room_subtitle">"通知整個聊天室"</string>
<string name="screen_room_mentions_at_room_title">"所有人"</string>
<string name="screen_room_retry_send_menu_send_again_action">"重傳"</string>
<string name="screen_room_retry_send_menu_title">"無法傳送您的訊息"</string>

View File

@@ -270,6 +270,8 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemImageContent(
body = "image.jpg",
formatted = null,
filename = null,
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = null,
mimeType = MimeTypes.Jpeg,
@@ -300,6 +302,8 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent(
body = "video.mp4",
formatted = null,
filename = null,
duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),

View File

@@ -227,12 +227,14 @@ class TimelineItemContentMessageFactoryTest {
fun `test create VideoMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = VideoMessageType("body", MediaSource("url"), null)),
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
body = "body",
formatted = null,
filename = null,
duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
@@ -253,7 +255,9 @@ class TimelineItemContentMessageFactoryTest {
val result = sut.create(
content = createMessageContent(
type = VideoMessageType(
body = "body.mp4",
body = "body.mp4 caption",
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
filename = "body.mp4",
source = MediaSource("url"),
info = VideoInfo(
duration = 1.minutes,
@@ -276,7 +280,9 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
body = "body.mp4",
body = "body.mp4 caption",
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
filename = "body.mp4",
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@@ -420,12 +426,14 @@ class TimelineItemContentMessageFactoryTest {
fun `test create ImageMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = ImageMessageType("body", MediaSource("url"), null)),
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
body = "body",
formatted = null,
filename = null,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
@@ -470,7 +478,9 @@ class TimelineItemContentMessageFactoryTest {
val result = sut.create(
content = createMessageContent(
type = ImageMessageType(
body = "body.jpg",
body = "body.jpg caption",
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
filename = "body.jpg",
source = MediaSource("url"),
info = ImageInfo(
height = 10L,
@@ -492,7 +502,9 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
body = "body.jpg",
body = "body.jpg caption",
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
filename = "body.jpg",
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "888 Bytes",

View File

@@ -83,6 +83,8 @@ class InReplyToMetadataKtTest {
eventContent = aMessageContent(
messageType = ImageMessageType(
body = "body",
formatted = null,
filename = null,
source = aMediaSource(),
info = anImageInfo(),
)
@@ -137,6 +139,8 @@ class InReplyToMetadataKtTest {
eventContent = aMessageContent(
messageType = VideoMessageType(
body = "body",
formatted = null,
filename = null,
source = aMediaSource(),
info = aVideoInfo(),
)

View File

@@ -41,6 +41,11 @@ class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
activeFilter = PollHistoryFilter.PAST,
currentItems = emptyList(),
),
aPollHistoryState(
activeFilter = PollHistoryFilter.PAST,
currentItems = emptyList(),
hasMoreToLoad = true,
),
)
}

View File

@@ -191,6 +191,7 @@ private fun PollHistoryList(
Column(
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
stringResource(R.string.screen_polls_history_empty_past)

View File

@@ -24,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType
@@ -35,8 +34,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@@ -58,11 +55,10 @@ class PreferencesRootPresenter @Inject constructor(
) : Presenter<PreferencesRootState> {
@Composable
override fun present(): PreferencesRootState {
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null)
}
val matrixUser = matrixClient.userProfile.collectAsState()
LaunchedEffect(Unit) {
initialLoad(matrixUser)
// Force a refresh of the profile
matrixClient.getUserProfile()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
@@ -121,10 +117,6 @@ class PreferencesRootPresenter @Inject constructor(
)
}
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
matrixUser.value = matrixClient.getCurrentUser()
}
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
devicesManagementUrl: MutableState<String?>,

View File

@@ -21,7 +21,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState(
val myUser: MatrixUser?,
val myUser: MatrixUser,
val version: String,
val deviceId: String?,
val showCompleteVerification: Boolean,

View File

@@ -18,10 +18,13 @@ package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
fun aPreferencesRootState() = PreferencesRootState(
myUser = null,
fun aPreferencesRootState(
myUser: MatrixUser,
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = "ILAKNDNASDLK",
showCompleteVerification = true,

View File

@@ -77,7 +77,7 @@ fun PreferencesRootView(
) {
UserPreferences(
modifier = Modifier.clickable {
state.myUser?.let(onOpenUserProfile)
onOpenUserProfile(state.myUser)
},
user = state.myUser,
)
@@ -225,7 +225,7 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
PreferencesRootView(
state = aPreferencesRootState().copy(myUser = matrixUser),
state = aPreferencesRootState(myUser = matrixUser),
onBackPressed = {},
onOpenAnalytics = {},
onOpenRageShake = {},

View File

@@ -6,10 +6,16 @@
<string name="screen_advanced_settings_element_call_base_url_description">"Tetapkan URL dasar khusus untuk Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL tidak valid, pastikan Anda menyertakan protokol (http/https) dan alamat yang benar."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Nonaktifkan penyunting teks kaya untuk mengetik Markdown secara manual."</string>
<string name="screen_advanced_settings_send_read_receipts">"Laporan dibaca"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Jika dimatikan, laporan dibaca Anda tidak akan dikirim kepada siapa pun. Anda masih akan menerima laporan dibaca dari pengguna lain."</string>
<string name="screen_advanced_settings_share_presence">"Bagikan presensi"</string>
<string name="screen_advanced_settings_share_presence_description">"Jika dimatikan, Anda tidak akan dapat mengirim atau menerima laporan dibaca atau notifikasi pengetikan"</string>
<string name="screen_advanced_settings_view_source_description">"Aktifkan opsi untuk melihat sumber pesan dalam lini masa."</string>
<string name="screen_blocked_users_empty">"Anda tidak memiliki pengguna yang diblokir"</string>
<string name="screen_blocked_users_unblock_alert_action">"Buka blokir"</string>
<string name="screen_blocked_users_unblock_alert_description">"Anda akan dapat melihat semua pesan dari mereka lagi."</string>
<string name="screen_blocked_users_unblock_alert_title">"Buka blokir pengguna"</string>
<string name="screen_blocked_users_unblocking">"Membatalkan pemblokiran…"</string>
<string name="screen_edit_profile_display_name">"Nama tampilan"</string>
<string name="screen_edit_profile_display_name_placeholder">"Nama tampilan Anda"</string>
<string name="screen_edit_profile_error">"Terjadi kesalahan yang tidak diketahui dan informasi tidak dapat diubah."</string>

View File

@@ -75,7 +75,13 @@ class PreferencesRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.myUser).isNull()
assertThat(initialState.myUser).isEqualTo(
MatrixUser(
userId = matrixClient.sessionId,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL
)
)
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem()
assertThat(loadedState.myUser).isEqualTo(

View File

@@ -2,5 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Пры апошнім выкарыстанні %1$s адбыўся збой. Жадаеце падзяліцца справаздачай аб збоі?"</string>
<string name="rageshake_detection_dialog_content">"Падобна, што вы трасеце тэлефон. Жадаеце адкрыць экран паведамлення пра памылку?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Парог выяўлення"</string>
</resources>

View File

@@ -7,9 +7,11 @@
<string name="screen_bug_report_editor_description">"Silakan jelaskan masalah tersebut. Apa yang Anda lakukan? Apa yang Anda harapkan untuk terjadi? Apa yang sebenarnya terjadi? Jelaskan sedetail mungkin."</string>
<string name="screen_bug_report_editor_placeholder">"Jelaskan masalah tersebut…"</string>
<string name="screen_bug_report_editor_supporting">"Jika memungkinkan, silakan tulis deskripsi dalam bahasa Inggris."</string>
<string name="screen_bug_report_error_description_too_short">"Deskripsinya terlalu pendek, silakan menyediakan detail tambahan tentang apa yang terjadi. Terima kasih!"</string>
<string name="screen_bug_report_include_crash_logs">"Kirim log kerusakan"</string>
<string name="screen_bug_report_include_logs">"Izinkan log"</string>
<string name="screen_bug_report_include_screenshot">"Kirim tangkapan layar"</string>
<string name="screen_bug_report_logs_description">"Log akan disertakan dengan pesan Anda untuk memastikan bahwa semuanya berfungsi dengan baik. Untuk mengirimkan pesan Anda tanpa log, matikan pengaturan ini."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s mengalami kemogokan saat terakhir kali digunakan. Apakah Anda ingin berbagi laporan kerusakan dengan kami?"</string>
<string name="screen_bug_report_view_logs">"Tampilkan catatan"</string>
</resources>

View File

@@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
@@ -34,7 +33,6 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
@@ -92,13 +90,6 @@ class RoomDetailsPresenter @Inject constructor(
}
}
// Update room members only when first presenting the node
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_CREATE) {
scope.launch { room.updateMembers() }
}
}
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)

View File

@@ -178,7 +178,7 @@ fun RoomDetailsView(
if (state.displayRolesAndPermissionsSettings) {
ListItem(
headlineContent = { Text("Roles and permissions") },
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = openAdminSettings,
)

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 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.roomdetails.impl.analytics
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.services.analytics.api.AnalyticsService
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
RoomMember.Role.ADMIN -> RoomModeration.Role.Administrator
RoomMember.Role.MODERATOR -> RoomModeration.Role.Moderator
RoomMember.Role.USER -> RoomModeration.Role.User
}
internal fun analyticsMemberRoleForPowerLevel(powerLevel: Long): RoomModeration.Role {
return RoomMember.Role.forPowerLevel(powerLevel).toAnalyticsMemberRole()
}
internal fun AnalyticsService.trackPermissionChangeAnalytics(initial: MatrixRoomPowerLevels?, updated: MatrixRoomPowerLevels) {
if (updated.ban != initial?.ban) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, analyticsMemberRoleForPowerLevel(updated.ban)))
}
if (updated.invite != initial?.invite) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, analyticsMemberRoleForPowerLevel(updated.invite)))
}
if (updated.kick != initial?.kick) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, analyticsMemberRoleForPowerLevel(updated.kick)))
}
if (updated.sendEvents != initial?.sendEvents) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.sendEvents)))
}
if (updated.redactEvents != initial?.redactEvents) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, analyticsMemberRoleForPowerLevel(updated.redactEvents)))
}
if (updated.roomName != initial?.roomName) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, analyticsMemberRoleForPowerLevel(updated.roomName)))
}
if (updated.roomAvatar != initial?.roomAvatar) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, analyticsMemberRoleForPowerLevel(updated.roomAvatar)))
}
if (updated.roomTopic != initial?.roomTopic) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, analyticsMemberRoleForPowerLevel(updated.roomTopic)))
}
}

View File

@@ -80,8 +80,6 @@ class RoomInviteMembersNode @AssistedInject constructor(
body = context.getString(CommonStrings.common_unable_to_invite_message),
)
}
room.updateMembers()
}
}
)

View File

@@ -30,11 +30,13 @@ class RoomMemberListDataSource @Inject constructor(
) {
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
val roomMembersState = room.membersStateFlow.value
val roomMembers = roomMembersState.roomMembers().orEmpty()
val activeRoomMembers = roomMembersState.roomMembers()
?.filter { it.membership.isActive() }
.orEmpty()
val filteredMembers = if (query.isBlank()) {
roomMembers
activeRoomMembers
} else {
roomMembers.filter { member ->
activeRoomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) ||
member.displayName?.contains(query, ignoreCase = true).orFalse()
}

View File

@@ -84,6 +84,11 @@ class RoomMemberListPresenter @AssistedInject constructor(
remember { roomMembersModerationPresenter.dummyState() }
}
// Ensure we load the latest data when entering this screen
LaunchedEffect(Unit) {
room.updateMembers()
}
LaunchedEffect(membersState) {
if (membersState is MatrixRoomMembersState.Unknown) {
return@LaunchedEffect

View File

@@ -67,7 +67,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
}
}
LaunchedEffect(Unit) {
room.updateMembers()
// Update room member info when opening this screen
// We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState`
room.getUpdatedMember(roomMemberId)
}
fun handleEvents(event: RoomMemberDetailsEvents) {
@@ -133,7 +135,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
.fold(
onSuccess = {
isBlockedState.value = AsyncData.Success(true)
room.updateMembers()
room.getUpdatedMember(userId)
},
onFailure = {
isBlockedState.value = AsyncData.Failure(it, false)
@@ -147,7 +149,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
.fold(
onSuccess = {
isBlockedState.value = AsyncData.Success(false)
room.updateMembers()
room.getUpdatedMember(userId)
},
onFailure = {
isBlockedState.value = AsyncData.Failure(it, true)

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -38,6 +39,7 @@ 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.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
@@ -51,6 +53,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : RoomMembersModerationPresenter {
private var selectedMember by mutableStateOf<RoomMember?>(null)
@@ -150,6 +153,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
userId: UserId,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
room.kickUser(userId).finally { selectedMember = null }
}
@@ -157,6 +161,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
userId: UserId,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
room.banUser(userId).finally { selectedMember = null }
}
@@ -164,6 +169,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember))
room.unbanUser(userId).finally { selectedMember = null }
}

View File

@@ -19,9 +19,6 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -33,10 +30,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
@@ -69,22 +64,12 @@ class RolesAndPermissionsNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
// Reload members when the user sees this screen
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_RESUME) {
lifecycleScope.launch { room.updateMembers() }
}
}
})
// If the user is not an admin anymore, exit this section since they won't have permissions to use it
lifecycleScope.launch {
room.membersStateFlow
.map { state ->
state.roomMembers().orEmpty().find { it.userId == room.sessionId }
room.roomInfoFlow
.filter { info ->
info.userPowerLevels[room.sessionId] != RoomMember.Role.ADMIN.powerLevel
}
.filter { it?.role != RoomMember.Role.ADMIN }
.take(1)
.onEach { navigateUp() }
.collect()

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
@@ -32,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,6 +41,7 @@ import javax.inject.Inject
class RolesAndPermissionsPresenter @Inject constructor(
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : Presenter<RolesAndPermissionsState> {
@Composable
override fun present(): RolesAndPermissionsState {
@@ -100,6 +103,7 @@ class RolesAndPermissionsPresenter @Inject constructor(
resetPermissionsAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
runUpdatingState(resetPermissionsAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ResetPermissions))
room.resetPowerLevels().map {}
}
}

View File

@@ -30,6 +30,8 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.analytics.toAnalyticsMemberRole
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction
@@ -41,6 +43,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
@@ -56,6 +59,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
@Assisted private val role: RoomMember.Role,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : Presenter<ChangeRolesState> {
@AssistedFactory
interface Factory {
@@ -197,9 +201,11 @@ class ChangeRolesPresenter @AssistedInject constructor(
val changes: List<UserRoleChange> = buildList {
for (selectedUser in toAdd) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole()))
add(UserRoleChange(selectedUser.userId, role))
}
for (selectedUser in toRemove) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
}
}
@@ -210,6 +216,8 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
.onSuccess {
saveState.value = AsyncAction.Success(Unit)
// Asynchronously reload the room members
launch { room.updateMembers() }
}
}
}

View File

@@ -27,10 +27,12 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.analytics.trackPermissionChangeAnalytics
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
@@ -39,6 +41,7 @@ import kotlinx.coroutines.launch
class ChangeRoomPermissionsPresenter @AssistedInject constructor(
@Assisted private val section: ChangeRoomPermissionsSection,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
) : Presenter<ChangeRoomPermissionsState> {
companion object {
internal fun itemsForSection(section: ChangeRoomPermissionsSection) = when (section) {
@@ -135,6 +138,7 @@ class ChangeRoomPermissionsPresenter @AssistedInject constructor(
}
room.updatePowerLevels(updatedRoomPowerLevels)
.onSuccess {
analyticsService.trackPermissionChangeAnalytics(initialPermissions, updatedRoomPowerLevels)
initialPermissions = currentPermissions
saveAction = AsyncAction.Success(Unit)
}

View File

@@ -30,6 +30,8 @@
<string name="screen_room_change_role_confirm_demote_self_description">"Вы не зможаце адмяніць гэтае змяненне, бо паніжаеце сябе. Калі вы апошні адміністратар у пакоі, вярнуць права будзе немагчыма."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Панізіць сябе?"</string>
<string name="screen_room_change_role_moderators_title">"Рэдагаваць мадэратараў"</string>
<string name="screen_room_change_role_unsaved_changes_description">"У вас ёсць незахаваныя змены."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Захаваць змены?"</string>
<string name="screen_room_details_add_topic_title">"Дадаць тэму"</string>
<string name="screen_room_details_already_a_member">"Ужо ўдзельнік"</string>
<string name="screen_room_details_already_invited">"Ужо запрасілі"</string>
@@ -56,27 +58,29 @@
<string name="screen_room_member_list_ban_member_confirmation_action">"Заблакіраваць"</string>
<string name="screen_room_member_list_ban_member_confirmation_description">"Яны не змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць."</string>
<string name="screen_room_member_list_ban_member_confirmation_title">"Вы ўпэўнены, што хочаце заблакіраваць гэтага карыстальніка?"</string>
<string name="screen_room_member_list_banned_empty">"У гэтым пакоі няма заблакіраваных удзельнікаў."</string>
<string name="screen_room_member_list_banning_user">"Блакіроўка %1$s"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d карыстальнік"</item>
<item quantity="few">"%1$d карыстальнікаў"</item>
<item quantity="many">"%1$d карыстальнікаў"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove">"Выдаліць удзельніка"</string>
<string name="screen_room_member_list_manage_member_ban">"Выдаліць і заблакіраваць удзельніка"</string>
<string name="screen_room_member_list_manage_member_remove">"Выдаліць удзельніка з пакоя"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Выдаліць і заблакіраваць удзельніка"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Толькі выдаліць удзельніка"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_title">"Выдаліць удзельніка і забараніць далучацца ў будучыні?"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Разблакіраваць"</string>
<string name="screen_room_member_list_manage_member_unban_message">"Яны змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Разблакіраваць карыстальніка"</string>
<string name="screen_room_member_list_manage_member_user_info">"Інфармацыю пра карыстальніка"</string>
<string name="screen_room_member_list_manage_member_unban_title">"Разблакіраваць удзельніка"</string>
<string name="screen_room_member_list_manage_member_user_info">"Інфармацыя пра ўдзельніка"</string>
<string name="screen_room_member_list_mode_banned">"Заблакіраваны"</string>
<string name="screen_room_member_list_mode_members">"Удзельнікі"</string>
<string name="screen_room_member_list_pending_header_title">"У чаканні"</string>
<string name="screen_room_member_list_removing_user">"Выдаленне %1$s …"</string>
<string name="screen_room_member_list_role_administrator">"Адміністратар"</string>
<string name="screen_room_member_list_role_moderator">"Мадэратар"</string>
<string name="screen_room_member_list_room_members_header_title">"Карыстальнікі пакоя"</string>
<string name="screen_room_member_list_room_members_header_title">"Удзельнікі пакоя"</string>
<string name="screen_room_member_list_unbanning_user">"Разблакіроўка %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Дазволіць карыстальніцкую наладу"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Калі гэта ўключыць, ваша налада па змаўчанні будзе адменена"</string>
@@ -93,11 +97,16 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Толькі згадванні і ключавыя словы"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"У гэтым пакоі паведаміце мяне пра"</string>
<string name="screen_room_roles_and_permissions_admins">"Адміністратары"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Змяніць маю роль"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Панізіць да ўдзельніка"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Панізіць да мадэратара"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Мадэрацыя ўдзельнікаў"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Паведамленні і змест"</string>
<string name="screen_room_roles_and_permissions_moderators">"Мадэратары"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Дазволы"</string>
<string name="screen_room_roles_and_permissions_reset">"Скінуць дазволы"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Пасля скіду дазволаў вы страціце бягучыя налады."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Скінуць дазволы?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Ролі"</string>
<string name="screen_room_roles_and_permissions_room_details">"Дэталі пакоя"</string>
<string name="screen_room_roles_and_permissions_title">"Ролі і дазволы"</string>

View File

@@ -11,7 +11,7 @@
<string name="screen_polls_history_title">"Umfragen"</string>
<string name="screen_room_change_permissions_administrators">"Nur Administratoren"</string>
<string name="screen_room_change_permissions_ban_people">"Mitglieder sperren"</string>
<string name="screen_room_change_permissions_delete_messages">"Nachrichten löschen"</string>
<string name="screen_room_change_permissions_delete_messages">"Nachrichten anderer Mitgliedern löschen"</string>
<string name="screen_room_change_permissions_everyone">"Alle"</string>
<string name="screen_room_change_permissions_invite_people">"Personen einladen"</string>
<string name="screen_room_change_permissions_member_moderation">"Moderation der Mitglieder"</string>

View File

@@ -103,9 +103,9 @@
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages et contenus"</string>
<string name="screen_room_roles_and_permissions_moderators">"Modérateurs"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Autorisations"</string>
<string name="screen_room_roles_and_permissions_reset">"Réinitialisation des permissions"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"La réinitialisation des permissions entraîne la perte des réglages actuels."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Réinitialisation des permissions?"</string>
<string name="screen_room_roles_and_permissions_reset">"Réinitialisation des autorisations"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"La réinitialisation des autorisations entraîne la perte des réglages actuels."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Réinitialisation des autorisations?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Rôles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_roles_and_permissions_title">"Rôles et autorisations"</string>

View File

@@ -9,7 +9,29 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Terjadi kesalahan saat memperbarui pengaturan pemberitahuan."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda mungkin tidak diberi tahu dalam beberapa ruangan."</string>
<string name="screen_polls_history_title">"Pemungutan suara"</string>
<string name="screen_room_change_permissions_administrators">"Hanya admin"</string>
<string name="screen_room_change_permissions_ban_people">"Cekal orang-orang"</string>
<string name="screen_room_change_permissions_delete_messages">"Hapus pesan"</string>
<string name="screen_room_change_permissions_everyone">"Semua orang"</string>
<string name="screen_room_change_permissions_invite_people">"Undang orang-orang"</string>
<string name="screen_room_change_permissions_member_moderation">"Moderasi anggota"</string>
<string name="screen_room_change_permissions_messages_and_content">"Pesan dan konten"</string>
<string name="screen_room_change_permissions_moderators">"Admin dan moderator"</string>
<string name="screen_room_change_permissions_remove_people">"Keluarkan orang-orang"</string>
<string name="screen_room_change_permissions_room_avatar">"Ubah avatar ruangan"</string>
<string name="screen_room_change_permissions_room_details">"Detail ruangan"</string>
<string name="screen_room_change_permissions_room_name">"Ubah nama ruangan"</string>
<string name="screen_room_change_permissions_room_topic">"Ubah topik ruangan"</string>
<string name="screen_room_change_permissions_send_messages">"Kirim pesan"</string>
<string name="screen_room_change_role_administrators_title">"Sunting Admin"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Anda tidak akan dapat mengurungkan tindakan ini. Anda mempromosikan pengguna untuk memiliki tingkat daya yang sama seperti Anda."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Tambahkan Admin?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Turunkan"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Anda tidak akan dapat mengurungkan perubahan ini karena Anda sedang menurunkan Anda sendiri, jika Anda merupakan pengguna dengan hak khusus dalam ruangan maka tidak akan memungkinkan untuk mendapatkan hak tersebut lagi."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Turunkan Anda sendiri?"</string>
<string name="screen_room_change_role_moderators_title">"Sunting Moderator"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Anda memiliki perubahan yang belum disimpan."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Simpan perubahan?"</string>
<string name="screen_room_details_add_topic_title">"Tambahkan topik"</string>
<string name="screen_room_details_already_a_member">"Sudah menjadi anggota"</string>
<string name="screen_room_details_already_invited">"Sudah diundang"</string>
@@ -22,20 +44,42 @@
<string name="screen_room_details_error_muting">"Gagal membisukan ruangan ini, silakan coba lagi."</string>
<string name="screen_room_details_error_unmuting">"Gagal membunyikan ruangan ini, silakan coba lagi."</string>
<string name="screen_room_details_invite_people_title">"Undang orang-orang"</string>
<string name="screen_room_details_leave_conversation_title">"Tinggalkan percakapan"</string>
<string name="screen_room_details_leave_room_title">"Tinggalkan ruangan"</string>
<string name="screen_room_details_notification_mode_custom">"Khusus"</string>
<string name="screen_room_details_notification_mode_default">"Bawaan"</string>
<string name="screen_room_details_notification_title">"Pemberitahuan"</string>
<string name="screen_room_details_roles_and_permissions">"Peran dan perizinan"</string>
<string name="screen_room_details_room_name_label">"Nama ruangan"</string>
<string name="screen_room_details_security_title">"Keamanan"</string>
<string name="screen_room_details_share_room_title">"Bagikan ruangan"</string>
<string name="screen_room_details_topic_title">"Topik"</string>
<string name="screen_room_details_updating_room">"Memperbarui ruangan…"</string>
<string name="screen_room_member_list_ban_member_confirmation_action">"Cekal"</string>
<string name="screen_room_member_list_ban_member_confirmation_description">"Mereka tidak akan dapat bergabung ke ruangan ini lagi jika diundang."</string>
<string name="screen_room_member_list_ban_member_confirmation_title">"Apakah Anda yakin ingin mencekal anggota ini?"</string>
<string name="screen_room_member_list_banned_empty">"Tidak ada pengguna yang dicekal dalam ruangan ini."</string>
<string name="screen_room_member_list_banning_user">"Mencekal %1$s"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="other">"%1$d orang"</item>
</plurals>
<string name="screen_room_member_list_manage_member_ban">"Keluarkan dan cekal anggota"</string>
<string name="screen_room_member_list_manage_member_remove">"Keluarkan dari ruangan"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Keluarkan dan cekal anggota"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Hanya keluarkan anggota"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_title">"Keluarkan pengguna dan cekal pengguna bergabung lagi di masa mendatang?"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Batalkan pencekalan"</string>
<string name="screen_room_member_list_manage_member_unban_message">"Pengguna dapat bergabung ke ruangan ini lagi jika diundang."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Batalkan pencekalan pengguna"</string>
<string name="screen_room_member_list_manage_member_user_info">"Tampilkan profil"</string>
<string name="screen_room_member_list_mode_banned">"Tercekal"</string>
<string name="screen_room_member_list_mode_members">"Anggota"</string>
<string name="screen_room_member_list_pending_header_title">"Tertunda"</string>
<string name="screen_room_member_list_removing_user">"Mengeluarkan %1$s…"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_room_members_header_title">"Anggota ruangan"</string>
<string name="screen_room_member_list_unbanning_user">"Membatalkan cekalan %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Izinkan pengaturan khusus"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Mengaktifkan ini akan mengganti pengaturan bawaan Anda"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Beri tahu saya di obrolan ini tentang"</string>
@@ -50,5 +94,19 @@
<string name="screen_room_notification_settings_mode_all_messages">"Semua pesan"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Sebutan dan Kata Kunci saja"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Di ruangan ini, beri tahu saya tentang"</string>
<string name="screen_room_roles_and_permissions_admins">"Admin"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Ubah peran saya"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Turunkan ke anggota"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Turunkan ke moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderasi anggota"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Pesan dan konten"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderator"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Perizinan"</string>
<string name="screen_room_roles_and_permissions_reset">"Atur ulang perizinan"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Setelah Anda mengatur ulang perizinan, Anda akan kehilangan pengaturan Anda saat ini."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Atur ulang perizinan?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Peran"</string>
<string name="screen_room_roles_and_permissions_room_details">"Detail ruangan"</string>
<string name="screen_room_roles_and_permissions_title">"Peran dan perizinan"</string>
<string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string>
</resources>

View File

@@ -17,6 +17,7 @@
<string name="screen_room_details_error_muting">"無法關閉聊天室通知,請再試一次。"</string>
<string name="screen_room_details_error_unmuting">"無法開啟聊天室通知,請再試一次。"</string>
<string name="screen_room_details_invite_people_title">"邀請夥伴"</string>
<string name="screen_room_details_leave_conversation_title">"離開對話"</string>
<string name="screen_room_details_leave_room_title">"離開聊天室"</string>
<string name="screen_room_details_notification_mode_custom">"自訂"</string>
<string name="screen_room_details_notification_mode_default">"預設"</string>

View File

@@ -223,7 +223,7 @@ private class FakeRoomMemberListNavigator : RoomMemberListNavigator {
var openRoomMemberDetailsCallCount = 0
private set
override fun openRoomMemberDetails(userId: UserId) {
override fun openRoomMemberDetails(roomMemberId: UserId) {
openRoomMemberDetailsCallCount++
}
}

View File

@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.moderation.DefaultRoomMembersModerationPresenter
@@ -36,6 +37,7 @@ 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.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
@@ -150,13 +152,14 @@ class DefaultRoomMembersModerationPresenterTests {
@Test
fun `present - Kick removes the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -170,18 +173,20 @@ class DefaultRoomMembersModerationPresenterTests {
assertThat(kickUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -200,11 +205,13 @@ class DefaultRoomMembersModerationPresenterTests {
assertThat(banUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
}
}
@Test
fun `present - UnbanUser requires confirmation and then unbans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
@@ -212,7 +219,7 @@ class DefaultRoomMembersModerationPresenterTests {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(selectedMember)))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -227,6 +234,7 @@ class DefaultRoomMembersModerationPresenterTests {
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.UnbanMember))
}
}
@@ -303,11 +311,13 @@ class DefaultRoomMembersModerationPresenterTests {
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): DefaultRoomMembersModerationPresenter {
return DefaultRoomMembersModerationPresenter(
room = matrixRoom,
featureFlagService = featureFlagService,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
}
}

View File

@@ -20,12 +20,14 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsEvents
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsPresenter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -120,7 +122,8 @@ class RolesAndPermissionPresenterTests {
@Test
fun `present - ResetPermissions needs confirmation, then resets permissions`() = runTest {
val presenter = createRolesAndPermissionsPresenter()
val analyticsService = FakeAnalyticsService()
val presenter = createRolesAndPermissionsPresenter(analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -131,6 +134,7 @@ class RolesAndPermissionPresenterTests {
assertThat(awaitItem().resetPermissionsAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().resetPermissionsAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ResetPermissions))
}
}
@@ -151,7 +155,12 @@ class RolesAndPermissionPresenterTests {
private fun TestScope.createRolesAndPermissionsPresenter(
room: FakeMatrixRoom = FakeMatrixRoom(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService()
): RolesAndPermissionsPresenter {
return RolesAndPermissionsPresenter(room = room, dispatchers = dispatchers)
return RolesAndPermissionsPresenter(
room = room,
dispatchers = dispatchers,
analyticsService = analyticsService
)
}
}

View File

@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
@@ -33,6 +34,7 @@ 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.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
@@ -315,11 +317,16 @@ class ChangeRolesPresenterTests {
@Test
fun `present - Save will just save the data for moderators`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 50)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.MODERATOR,
room = room,
analyticsService = analyticsService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -331,6 +338,7 @@ class ChangeRolesPresenterTests {
awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator))
}
}
@@ -364,11 +372,13 @@ class ChangeRolesPresenterTests {
role: RoomMember.Role = RoomMember.Role.ADMIN,
room: FakeMatrixRoom = FakeMatrixRoom(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): ChangeRolesPresenter {
return ChangeRolesPresenter(
role = role,
room = room,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
}
}

View File

@@ -22,6 +22,7 @@ import app.cash.turbine.Event
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsEvent
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsPresenter
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsSection
@@ -30,9 +31,11 @@ import io.element.android.features.roomdetails.impl.rolesandpermissions.permissi
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember.Role.ADMIN
import io.element.android.libraries.matrix.api.room.RoomMember.Role.MODERATOR
import io.element.android.libraries.matrix.api.room.RoomMember.Role.USER
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevels
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -160,7 +163,8 @@ class ChangeRoomPermissionsPresenterTests {
@Test
fun `present - Save updates the current permissions and resets hasChanges`() = runTest {
val presenter = createChangeRoomPermissionsPresenter()
val analyticsService = FakeAnalyticsService()
val presenter = createChangeRoomPermissionsPresenter(analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -169,6 +173,14 @@ class ChangeRoomPermissionsPresenterTests {
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, USER))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, ADMIN))
skipItems(7)
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Save)
@@ -179,6 +191,18 @@ class ChangeRoomPermissionsPresenterTests {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
assertThat(analyticsService.capturedEvents).containsExactlyElementsIn(
listOf(
RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Moderator),
RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Moderator),
RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Moderator),
RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Moderator),
RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.User),
RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, RoomModeration.Role.Administrator),
RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, RoomModeration.Role.Administrator),
RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, RoomModeration.Role.Administrator),
)
)
}
}
@@ -269,9 +293,11 @@ class ChangeRoomPermissionsPresenterTests {
private fun createChangeRoomPermissionsPresenter(
section: ChangeRoomPermissionsSection = ChangeRoomPermissionsSection.RoomDetails,
room: FakeMatrixRoom = FakeMatrixRoom(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = ChangeRoomPermissionsPresenter(
section = section,
room = room,
analyticsService = analyticsService,
)
private fun defaultPermissions() = defaultRoomPowerLevels().run {

View File

@@ -58,8 +58,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -101,16 +99,15 @@ class RoomListPresenter @Inject constructor(
override fun present(): RoomListState {
val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null)
}
val matrixUser = client.userProfile.collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
initialLoad(matrixUser)
// Force a refresh of the profile
client.getUserProfile()
}
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
@@ -157,10 +154,6 @@ class RoomListPresenter @Inject constructor(
)
}
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
matrixUser.value = client.getCurrentUser()
}
@Composable
private fun securityBannerState(
securityBannerDismissed: Boolean,

View File

@@ -28,7 +28,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
data class RoomListState(
val matrixUser: MatrixUser?,
val matrixUser: MatrixUser,
val showAvatarIndicator: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,

View File

@@ -49,14 +49,14 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = null, contentState = aMigrationContentState()),
aRoomListState(matrixUser = MatrixUser(userId = UserId("@id:domain")), contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
)
}
internal fun aRoomListState(
matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false,
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,

View File

@@ -21,10 +21,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
@@ -73,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
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.MediumTopAppBar
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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -87,7 +84,7 @@ private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
matrixUser: MatrixUser?,
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
onToggleSearch: () -> Unit,
@@ -117,7 +114,7 @@ fun RoomListTopBar(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
@@ -142,7 +139,7 @@ private fun DefaultRoomListTopBar(
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser?.getAvatarData(size = AvatarSize.CurrentUserTopBar)
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
@@ -295,7 +292,7 @@ private fun DefaultRoomListTopBar(
@Composable
private fun NavigationIcon(
avatarData: AvatarData?,
avatarData: AvatarData,
showAvatarIndicator: Boolean,
onClick: () -> Unit,
) {
@@ -304,20 +301,10 @@ private fun NavigationIcon(
onClick = onClick,
) {
Box {
if (avatarData != null) {
Avatar(
avatarData = avatarData,
contentDescription = stringResource(CommonStrings.common_settings),
)
} else {
// Placeholder avatar until the avatarData is available
Surface(
modifier = Modifier.size(AvatarSize.CurrentUserTopBar.dp),
shape = CircleShape,
color = ElementTheme.colors.iconSecondary,
content = {}
)
}
Avatar(
avatarData = avatarData,
contentDescription = stringResource(CommonStrings.common_settings),
)
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)

View File

@@ -8,13 +8,23 @@
<string name="screen_roomlist_empty_message">"Пачніце з паведамлення каму-небудзь."</string>
<string name="screen_roomlist_empty_title">"Пакуль няма чатаў."</string>
<string name="screen_roomlist_filter_favourites">"Абранае"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Дадаць чат у абранае можна ў наладах чата.
На дадзены момант вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"У вас пакуль няма абраных чатаў"</string>
<string name="screen_roomlist_filter_low_priority">"Нізкі прыярытэт"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"У вас няма чатаў для гэтай катэгорыі"</string>
<string name="screen_roomlist_filter_people">"Людзі"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"У вас пакуль няма асабістых паведамленняў"</string>
<string name="screen_roomlist_filter_rooms">"Пакоі"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Вас пакуль няма ў ніводным пакоі"</string>
<string name="screen_roomlist_filter_unreads">"Непрачытаныя"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Віншуем!
У вас няма непрачытаных паведамленняў!"</string>
<string name="screen_roomlist_main_space_title">"Усе чаты"</string>
<string name="screen_roomlist_mark_as_read">"Пазначыць як прачытанае"</string>
<string name="screen_roomlist_mark_as_unread">"Пазначыць як непрачытанае"</string>
<string name="screen_roomlist_room_directory_button_title">"Праглядзець усе пакоі"</string>
<string name="session_verification_banner_message">"Здаецца, вы карыстаецеся новай прыладай. Праверце з дапамогай іншай прылады, каб атрымаць доступ да зашыфраваных паведамленняў."</string>
<string name="session_verification_banner_title">"Пацвердзіце, што гэта вы"</string>
</resources>

View File

@@ -24,6 +24,7 @@ Nemáte žádné nepřečtené zprávy!"</string>
<string name="screen_roomlist_main_space_title">"Všechny chaty"</string>
<string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string>
<string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string>
<string name="screen_roomlist_room_directory_button_title">"Procházet všechny místnosti"</string>
<string name="session_verification_banner_message">"Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."</string>
<string name="session_verification_banner_title">"Ověřte, že jste to vy"</string>
</resources>

View File

@@ -24,6 +24,7 @@ Du hast keine ungelesenen Nachrichten!"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Als gelesen markieren"</string>
<string name="screen_roomlist_mark_as_unread">"Als ungelesen markieren"</string>
<string name="screen_roomlist_room_directory_button_title">"Alle Räume durchsuchen"</string>
<string name="session_verification_banner_message">"Es sieht aus, als würdest du ein neues Gerät verwenden. Verifiziere es mit einem anderen Gerät, damit du auf deine verschlüsselten Nachrichten zugreifen kannst."</string>
<string name="session_verification_banner_title">"Bestätige deine Identität"</string>
</resources>

View File

@@ -24,6 +24,7 @@ Vous navez plus de messages non-lus!"</string>
<string name="screen_roomlist_main_space_title">"Conversations"</string>
<string name="screen_roomlist_mark_as_read">"Marquer comme lu"</string>
<string name="screen_roomlist_mark_as_unread">"Marquer comme non lu"</string>
<string name="screen_roomlist_room_directory_button_title">"Parcourir tous les salons"</string>
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés."</string>
<string name="session_verification_banner_title">"Vérifier que cest bien vous"</string>
</resources>

View File

@@ -7,8 +7,23 @@
<string name="screen_roomlist_a11y_create_message">"Buat percakapan atau ruangan baru"</string>
<string name="screen_roomlist_empty_message">"Mulailah dengan mengirim pesan kepada seseorang."</string>
<string name="screen_roomlist_empty_title">"Belum ada obrolan."</string>
<string name="screen_roomlist_filter_favourites">"Favorit"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Anda dapat menambahkan percakapan ke favorit Anda dalam pengaturan percakapan.
Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Anda belum memiliki percakapan favorit"</string>
<string name="screen_roomlist_filter_low_priority">"Prioritas Rendah"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Anda tidak memiliki percakapan untuk pemilihan ini"</string>
<string name="screen_roomlist_filter_people">"Orang"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Anda belum memiliki percakapan langsung"</string>
<string name="screen_roomlist_filter_rooms">"Ruangan"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Anda belum berada dalam ruangan"</string>
<string name="screen_roomlist_filter_unreads">"Belum dibaca"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Selamat!
Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_main_space_title">"Semua Obrolan"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="session_verification_banner_message">"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."</string>
<string name="session_verification_banner_title">"Verifikasi bahwa ini Anda"</string>
</resources>

View File

@@ -24,6 +24,7 @@
<string name="screen_roomlist_main_space_title">"Все чаты"</string>
<string name="screen_roomlist_mark_as_read">"Пометить как прочитанное"</string>
<string name="screen_roomlist_mark_as_unread">"Пометить как непрочитанное"</string>
<string name="screen_roomlist_room_directory_button_title">"Просмотреть все комнаты"</string>
<string name="session_verification_banner_message">"Похоже, вы используете новое устройство. Чтобы получить доступ к зашифрованным сообщениям пройдите верификацию с другим устройством."</string>
<string name="session_verification_banner_title">"Подтвердите, что это вы"</string>
</resources>

View File

@@ -24,6 +24,7 @@ Nemáte žiadne neprečítané správy!"</string>
<string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string>
<string name="screen_roomlist_mark_as_read">"Označiť ako prečítané"</string>
<string name="screen_roomlist_mark_as_unread">"Označiť ako neprečítané"</string>
<string name="screen_roomlist_room_directory_button_title">"Prehliadať všetky miestnosti"</string>
<string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."</string>
<string name="session_verification_banner_title">"Overte, že ste to vy"</string>
</resources>

View File

@@ -24,6 +24,7 @@ You dont have any unread messages!"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>
<string name="screen_roomlist_room_directory_button_title">"Browse all rooms"</string>
<string name="session_verification_banner_message">"Looks like youre using a new device. Verify with another device to access your encrypted messages."</string>
<string name="session_verification_banner_title">"Verify its you"</string>
</resources>

View File

@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -93,17 +94,24 @@ class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
val matrixClient = FakeMatrixClient(
userDisplayName = null,
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
val withUserState = awaitItem()
assertThat(withUserState.matrixUser).isNotNull()
assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.showAvatarIndicator).isTrue()
scope.cancel()
}
@@ -128,7 +136,6 @@ class RoomListPresenterTests {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false)
assertThat(awaitItem().showAvatarIndicator).isTrue()
encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isFalse()
@@ -139,19 +146,18 @@ class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient(
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarUrl = Result.failure(AN_EXCEPTION),
userDisplayName = null,
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
assertThat(withUserState.matrixUser).isNotNull()
scope.cancel()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
}
}
@@ -364,7 +370,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
@@ -414,8 +419,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
@@ -473,7 +476,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
eventRecorder.assertEmpty()
initialState.eventSink(RoomListEvents.ToggleSearchResults)
@@ -558,7 +560,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
// The migration screen is shown if the migration screen has not been shown before
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java)
@@ -585,7 +586,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
scope.cancel()
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Stäng av säkerhetskopiering"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Slå på säkerhetskopiering"</string>
<string name="screen_chat_backup_key_backup_description">"Säkerhetskopior ser till att du inte blir av med din meddelandehistorik. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Säkerhetskopia"</string>
<string name="screen_chat_backup_recovery_action_setup">"Ställ in återställning"</string>
</resources>

View File

@@ -6,6 +6,7 @@
<string name="screen_session_verification_compare_numbers_subtitle">"Konfirmasikan bahwa angka-angka di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain."</string>
<string name="screen_session_verification_compare_numbers_title">"Bandingkan angka"</string>
<string name="screen_session_verification_complete_subtitle">"Sesi baru Anda sekarang diverifikasi. Ini memiliki akses ke pesan terenkripsi Anda, dan pengguna lain akan melihatnya sebagai tepercaya."</string>
<string name="screen_session_verification_enter_recovery_key">"Masukkan kunci pemulihan"</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Buktikan bahwa ini memang Anda untuk mengakses riwayat pesan terenkripsi Anda."</string>
<string name="screen_session_verification_open_existing_session_title">"Buka sesi yang sudah ada"</string>
<string name="screen_session_verification_positive_button_canceled">"Verifikasi ulang"</string>

View File

@@ -49,7 +49,7 @@ signing.element.nightly.keyPassword=Secret
# Customise the Lint version to use a more recent version than the one bundled with AGP
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
android.experimental.lint.version=8.3.0-alpha12
android.experimental.lint.version=8.4.0-alpha13
# Enable test fixture for all modules by default
android.experimental.enableTestFixtures=true

View File

@@ -3,9 +3,9 @@
[versions]
# Project
android_gradle_plugin = "8.2.2"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.17"
android_gradle_plugin = "8.3.1"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.19"
firebaseAppDistribution = "4.2.0"
# AndroidX
@@ -18,8 +18,8 @@ activity = "1.8.2"
media3 = "1.3.0"
# Compose
compose_bom = "2024.02.02"
composecompiler = "1.5.10"
compose_bom = "2024.03.00"
composecompiler = "1.5.11"
# Coroutines
coroutines = "1.8.0"
@@ -38,8 +38,8 @@ serialization_json = "1.6.3"
showkase = "1.0.2"
appyx = "1.4.0"
sqldelight = "2.0.1"
wysiwyg = "2.33.0"
telephoto = "0.8.0"
wysiwyg = "2.34.0"
telephoto = "0.9.0"
# DI
dagger = "2.51"
@@ -63,7 +63,7 @@ kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.1"
# https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:32.7.4"
google_firebase_bom = "com.google.firebase:firebase-bom:32.8.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
@@ -120,8 +120,9 @@ network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_mockwebserver = { module = "com.squareup.okhttp3:mockwebserver" }
network_retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
network_retrofit_converter_serialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0"
network_retrofit_bom = "com.squareup.retrofit2:retrofit-bom:2.10.0"
network_retrofit = { module = "com.squareup.retrofit2:retrofit" }
network_retrofit_converter_serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization" }
# Test
test_core = { module = "androidx.test:core", version.ref = "test_core" }
@@ -153,7 +154,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.1"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.9"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.12"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -163,7 +164,7 @@ sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.1"
@@ -213,10 +214,10 @@ kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.5"
detekt = "io.gitlab.arturbosch.detekt:1.23.6"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:9.0.9"
dependencycheck = "org.owasp.dependencycheck:9.0.10"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.3"
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }

View File

@@ -20,6 +20,10 @@ plugins {
android {
namespace = "io.element.android.libraries.androidutils"
buildFeatures {
buildConfig = true
}
}
anvil {

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 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.libraries.androidutils.metadata
import io.element.android.libraries.androidutils.BuildConfig
/**
* true if the app is built in debug mode.
* For testing purpose, this can be changed with [withReleaseBehavior].
*/
var isInDebug: Boolean = BuildConfig.DEBUG
private set
/**
* Run the lambda simulating the app is in release mode.
*
* **IMPORTANT**: this should **ONLY** be used for testing purposes.
*/
fun withReleaseBehavior(lambda: () -> Unit) {
isInDebug = false
lambda()
isInDebug = BuildConfig.DEBUG
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2024 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.libraries.designsystem.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bigCheckmarkBorderColor
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Compound component that displays a big checkmark centered in a rounded square.
*
* @param modifier the modifier to apply to this layout
*/
@Composable
fun BigCheckmark(
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(120.dp),
shape = RoundedCornerShape(14.dp),
color = ElementTheme.colors.bgCanvasDefault,
border = BorderStroke(1.dp, ElementTheme.colors.bigCheckmarkBorderColor),
shadowElevation = 4.dp,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
modifier = Modifier.size(72.dp),
tint = ElementTheme.colors.iconSuccessPrimary,
imageVector = CompoundIcons.CheckCircleSolid(),
contentDescription = stringResource(CommonStrings.common_success)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun BigCheckmarkPreview() {
ElementPreview {
Box(
modifier = Modifier.padding(10.dp),
contentAlignment = Alignment.Center,
) {
BigCheckmark()
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright (c) 2024 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.libraries.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CatchingPokemon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bigIconDefaultBackgroundColor
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Compound component that display a big icon centered in a rounded square.
*/
object BigIcon {
/**
* The style of the [BigIcon].
*/
@Immutable
sealed interface Style {
/**
* The default style.
*
* @param vectorIcon the [ImageVector] to display
* @param contentDescription the content description of the icon, if any. It defaults to `null`
*/
data class Default(val vectorIcon: ImageVector, val contentDescription: String? = null) : Style
/**
* An alert style with a transparent background.
*/
data object Alert : Style
/**
* An alert style with a tinted background.
*/
data object AlertSolid : Style
/**
* A success style with a transparent background.
*/
data object Success : Style
/**
* A success style with a tinted background.
*/
data object SuccessSolid : Style
}
/**
* Display a [BigIcon].
*
* @param style the style of the icon
* @param modifier the modifier to apply to this layout
*/
@Composable
operator fun invoke(
style: Style,
modifier: Modifier = Modifier,
) {
val backgroundColor = when (style) {
is Style.Default -> ElementTheme.colors.bigIconDefaultBackgroundColor
Style.Alert, Style.Success -> Color.Transparent
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
}
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert, Style.AlertSolid -> CompoundIcons.Error()
Style.Success, Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert, Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success, Style.SuccessSolid -> stringResource(CommonStrings.common_success)
}
val iconTint = when (style) {
is Style.Default -> ElementTheme.colors.iconSecondaryAlpha
Style.Alert, Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success, Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
}
Box(
modifier = modifier
.size(64.dp)
.clip(RoundedCornerShape(14.dp))
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
}
}
}
@PreviewsDayNight
@Composable
internal fun BigIconPreview() {
ElementPreview {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(10.dp)) {
val provider = BigIconStylePreviewProvider()
for (style in provider.values) {
BigIcon(style = style)
}
}
}
}
internal class BigIconStylePreviewProvider : PreviewParameterProvider<BigIcon.Style> {
override val values: Sequence<BigIcon.Style>
get() = sequenceOf(
BigIcon.Style.Default(Icons.Filled.CatchingPokemon),
BigIcon.Style.Alert,
BigIcon.Style.AlertSolid,
BigIcon.Style.Success,
BigIcon.Style.SuccessSolid
)
}

View File

@@ -302,7 +302,7 @@ fun Modifier.bloom(
/**
* Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used. If `null` is passed, no bloom effect will be applied.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
@@ -313,7 +313,7 @@ fun Modifier.bloom(
* @param alpha The alpha value to apply to the bloom effect.
*/
fun Modifier.avatarBloom(
avatarData: AvatarData?,
avatarData: AvatarData,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
@@ -327,7 +327,6 @@ fun Modifier.avatarBloom(
) = composed {
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
avatarData ?: return@composed this
// Request the avatar contents to use as the bloom source
val context = LocalContext.current

View File

@@ -0,0 +1,137 @@
/*
* Copyright (c) 2024 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.libraries.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
/**
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
*
* @param title the title to display
* @param iconStyle the style of the [BigIcon] to display
* @param modifier the modifier to apply to this layout
* @param subtitle the optional subtitle to display. It defaults to `null`
* @param callToAction the optional call to action component to display. It defaults to `null`
*/
@Composable
fun PageTitle(
title: AnnotatedString,
iconStyle: BigIcon.Style,
modifier: Modifier = Modifier,
subtitle: AnnotatedString? = null,
callToAction: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(style = iconStyle)
Column(
modifier = Modifier.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
subtitle?.let {
Text(
modifier = Modifier.fillMaxWidth(),
text = it,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
callToAction?.invoke()
}
}
/**
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
*
* @param title the title to display
* @param iconStyle the style of the [BigIcon] to display
* @param modifier the modifier to apply to this layout
* @param subtitle the optional subtitle to display. It defaults to `null`
* @param callToAction the optional call to action component to display. It defaults to `null`
*/
@Composable
fun PageTitle(
title: String,
iconStyle: BigIcon.Style,
modifier: Modifier = Modifier,
subtitle: String? = null,
callToAction: @Composable (() -> Unit)? = null,
) = PageTitle(
title = AnnotatedString(title),
iconStyle = iconStyle,
modifier = modifier,
subtitle = subtitle?.let { AnnotatedString(it) },
callToAction = callToAction
)
@PreviewsDayNight
@Composable
internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),
title = AnnotatedString("Headline"),
subtitle = AnnotatedString("Description goes here"),
iconStyle = style,
callToAction = {
TextButton(text = "Learn more", onClick = {})
}
)
}
}
@PreviewsDayNight
@Composable
internal fun TitleWithIconMinimalPreview() {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),
title = "Headline",
iconStyle = BigIcon.Style.Default(CompoundIcons.CheckCircleSolid()),
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 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.
@@ -14,27 +14,22 @@
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
package io.element.android.libraries.designsystem.components.blurhash
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.vanniktech.blurhash.BlurHash
@Composable
fun BlurHashAsyncImage(
@@ -69,31 +64,3 @@ fun BlurHashAsyncImage(
}
}
}
@Composable
private fun BlurHashImage(
blurHash: String?,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
if (blurHash == null) return
val bitmapState = remember(blurHash) {
mutableStateOf(
// Build a small blurhash image so that it's fast
BlurHash.decode(blurHash, 10, 10)
)
}
DisposableEffect(blurHash) {
onDispose {
bitmapState.value?.recycle()
}
}
bitmapState.value?.let { bitmap ->
Image(
modifier = Modifier.fillMaxSize(),
bitmap = bitmap.asImageBitmap(),
contentScale = contentScale,
contentDescription = contentDescription
)
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 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.libraries.designsystem.components.blurhash
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.unit.IntSize
fun Modifier.blurHashBackground(blurHash: String?, alpha: Float = 1f) = this.composed {
val blurHashBitmap = rememberBlurHashImage(blurHash)
if (blurHashBitmap != null) {
Modifier.drawBehind {
drawImage(blurHashBitmap, dstSize = IntSize(size.width.toInt(), size.height.toInt()), alpha = alpha)
}
} else {
this
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024 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.libraries.designsystem.components.blurhash
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import com.vanniktech.blurhash.BlurHash
@Suppress("ModifierMissing")
@Composable
fun BlurHashImage(
blurHash: String?,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
if (blurHash == null) return
val blurHashImage = rememberBlurHashImage(blurHash)
blurHashImage?.let { bitmap ->
Image(
modifier = Modifier.fillMaxSize(),
bitmap = bitmap,
contentScale = contentScale,
contentDescription = contentDescription
)
}
}
@Composable
fun rememberBlurHashImage(blurHash: String?): ImageBitmap? {
return if (LocalInspectionMode.current) {
blurHash?.let { BlurHash.decode(it, 10, 10)?.asImageBitmap() }
} else {
produceState<ImageBitmap?>(initialValue = null, blurHash) {
blurHash?.let { value = BlurHash.decode(it, 10, 10)?.asImageBitmap() }
}.value
}
}

View File

@@ -19,9 +19,12 @@ package io.element.android.libraries.designsystem.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.element.android.compound.annotations.CoreColorToken
import io.element.android.compound.previews.ColorListPreview
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
import io.element.android.compound.tokens.generated.internal.LightColorTokens
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.persistentMapOf
@@ -138,6 +141,14 @@ val SemanticColors.mentionPillBackground
Color(0x26f4f7fa)
}
@OptIn(CoreColorToken::class)
val SemanticColors.bigIconDefaultBackgroundColor
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
@OptIn(CoreColorToken::class)
val SemanticColors.bigCheckmarkBorderColor
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {
@@ -155,6 +166,7 @@ internal fun ColorAliasesPreview() = ElementPreview {
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
)
)
}

View File

@@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(аватар таксама быў зменены)"</string>
<string name="state_event_avatar_url_changed">"%1$s змяніў аватар"</string>
<string name="state_event_avatar_url_changed_by_you">"Вы змянілі свой аватар"</string>
<string name="state_event_demoted_to_member">"%1$s быў паніжаны да ўдзельніка"</string>
<string name="state_event_demoted_to_moderator">"%1$s быў паніжаны да мадэратара"</string>
<string name="state_event_display_name_changed_from">"%1$s змяніў сваё адлюстраванае імя з %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы змянілі сваё адлюстраванае імя з %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s выдаліў сваё адлюстраванае імя (яно было %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Вы выдалілі сваё адлюстраванае імя (яно было %1$s)"</string>
<string name="state_event_display_name_set">"%1$s усталявалі сваё адлюстраванае імя на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Вы ўстанавілі адлюстраванае імя на %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s быў павышаны да адміністратара"</string>
<string name="state_event_promoted_to_moderator">"%1$s быў павышаны да мадэратара"</string>
<string name="state_event_room_avatar_changed">"%1$s змяніў аватар пакоя"</string>
<string name="state_event_room_avatar_changed_by_you">"Вы змянілі аватар пакоя"</string>
<string name="state_event_room_avatar_removed">"%1$s выдаліў(-ла) аватар пакоя"</string>

View File

@@ -3,12 +3,16 @@
<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_demoted_to_member">"%1$s byl(a) degradován(a) na člena"</string>
<string name="state_event_demoted_to_moderator">"%1$s byl(a) degradován(a) na moderátora"</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_promoted_to_administrator">"%1$s byl(a) povýšen(a) na administrátora"</string>
<string name="state_event_promoted_to_moderator">"%1$s byl(a) povýšen(a) na moderátora"</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>

View File

@@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(avatar juga diubah)"</string>
<string name="state_event_avatar_url_changed">"%1$s mengubah avatarnya"</string>
<string name="state_event_avatar_url_changed_by_you">"Anda mengubah avatar sendiri"</string>
<string name="state_event_demoted_to_member">"%1$s telah diturunkan menjadi anggota"</string>
<string name="state_event_demoted_to_moderator">"%1$s telah diturunkan menjadi moderator"</string>
<string name="state_event_display_name_changed_from">"%1$s mengubah nama tampilannya dari %2$s menjadi %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Anda mengubah nama tampilan sendiri dari %1$s menjadi %2$s"</string>
<string name="state_event_display_name_removed">"%1$s menghapus nama tampilannya (sebelumnya %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Anda menghapus nama tampilan sendiri (sebelumnya %1$s)"</string>
<string name="state_event_display_name_set">"%1$s menetapkan nama tampilannya menjadi %2$s"</string>
<string name="state_event_display_name_set_by_you">"Anda menetapkan nama tampilan sendiri menjadi %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s telah dipromosikan menjadi admin"</string>
<string name="state_event_promoted_to_moderator">"%1$s telah dipromosikan menjadi moderator"</string>
<string name="state_event_room_avatar_changed">"%1$s mengubah avatar ruangan"</string>
<string name="state_event_room_avatar_changed_by_you">"Anda mengubah avatar ruangan"</string>
<string name="state_event_room_avatar_removed">"%1$s menghapus avatar ruangan"</string>
@@ -39,6 +43,8 @@
<string name="state_event_room_name_changed_by_you">"Anda mengubah nama ruangan menjadi: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s menghapus nama ruangan"</string>
<string name="state_event_room_name_removed_by_you">"Anda menghapus nama ruangan"</string>
<string name="state_event_room_none">"%1$s tidak membuat perubahan"</string>
<string name="state_event_room_none_by_you">"Anda tidak membuat perubahan"</string>
<string name="state_event_room_reject">"%1$s menolak undangan"</string>
<string name="state_event_room_reject_by_you">"Anda menolak undangan"</string>
<string name="state_event_room_remove">"%1$s mengeluarkan %2$s"</string>

View File

@@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(аватар теж було змінено)"</string>
<string name="state_event_avatar_url_changed">"%1$s змінив (-ла) свій аватар"</string>
<string name="state_event_avatar_url_changed_by_you">"Ви змінили свій аватар"</string>
<string name="state_event_demoted_to_member">"%1$s був понижений до члена"</string>
<string name="state_event_demoted_to_moderator">"%1$s був понижений до модератора"</string>
<string name="state_event_display_name_changed_from">"%1$s змінив (-ла) своє імʼя з %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Ви змінили своє ім\'я з %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s видалив (-ла) своє ім\'я (було %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Ви видалили своє ім\'я (було%1$s)"</string>
<string name="state_event_display_name_set">"%1$s змінив (-ла) своє ім\'я на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Ви змінили своє імʼя на %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s був підвищений до адміністратора"</string>
<string name="state_event_promoted_to_moderator">"%1$s був підвищений до модератора"</string>
<string name="state_event_room_avatar_changed">"%1$s змінив (-ла) аватар кімнати"</string>
<string name="state_event_room_avatar_changed_by_you">"Ви змінили аватар кімнати"</string>
<string name="state_event_room_avatar_removed">"%1$s видалив (-ла) аватар кімнати"</string>

View File

@@ -161,10 +161,10 @@ class DefaultRoomLastMessageFormatterTest {
val sharedContentMessagesTypes = arrayOf(
TextMessageType(body, null),
VideoMessageType(body, MediaSource("url"), null),
VideoMessageType(body, null, null, MediaSource("url"), null),
AudioMessageType(body, MediaSource("url"), null),
VoiceMessageType(body, MediaSource("url"), null, null),
ImageMessageType(body, MediaSource("url"), null),
ImageMessageType(body, null, null, MediaSource("url"), null),
StickerMessageType(body, MediaSource("url"), null),
FileMessageType(body, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null),

View File

@@ -39,8 +39,8 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.VoiceMessages -> true
FeatureFlags.PinUnlock -> true
FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> false
FeatureFlags.RoomListFilters -> false
FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomListFilters -> true
FeatureFlags.RoomModeration -> false
}
} else {

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