Merge branch 'release/25.03.1' into main

This commit is contained in:
Benoit Marty 2025-03-06 16:58:44 +01:00
commit bd33d507e3
675 changed files with 2857 additions and 2499 deletions

17
.github/workflows/blocked.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: Prevent blocked
on:
pull_request:
types: [opened, labeled, unlabeled]
jobs:
prevent-blocked:
name: Prevent blocked
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@v7
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");

View file

@ -29,7 +29,7 @@ jobs:
- name: Run code quality check suite - name: Run code quality check suite
run: ./tools/check/check_code_quality.sh run: ./tools/check/check_code_quality.sh
checkScreesnhot: checkScreenshot:
name: Search for invalid screenshot files name: Search for invalid screenshot files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View file

@ -1,3 +1,80 @@
Changes in Element X v25.03.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.03.0 -->
## What's Changed
### ✨ Features
* Create `SyncOrchestrator` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4176
* feature(crypto): verification violation handling and block sending by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/4126
* Update Matrix Room API and allow media swipe on pinned event only. by @bmarty in https://github.com/element-hq/element-x-android/pull/4274
* Feature : join room by address by @ganfra in https://github.com/element-hq/element-x-android/pull/4302
### 🙌 Improvements
* Change : Room Preview by @ganfra in https://github.com/element-hq/element-x-android/pull/4250
### 🐛 Bugfixes
* SyncOrchestrator: restore the initial sync step by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4242
* When an emoji is used as the 'initial' for an avatar, use the whole emoji by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4277
* Try avoiding trailing punctuation inside linkified URLs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4214
* Preload account urls by @bmarty in https://github.com/element-hq/element-x-android/pull/4301
* Fix issues due to multiple ntfy applications with the same name. by @bmarty in https://github.com/element-hq/element-x-android/pull/4312
* Use `Settings.System.DEFAULT_RINGTONE_URI` for ringing notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4310
### 🗣 Translations
* Sync Strings - New translations to turkish by @ElementBot in https://github.com/element-hq/element-x-android/pull/4253
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4298
### 🧱 Build
* Fix nightly reports by @bmarty in https://github.com/element-hq/element-x-android/pull/4235
* Fix nightly reports - next step by @bmarty in https://github.com/element-hq/element-x-android/pull/4239
* Prepare application for being configurable by @bmarty in https://github.com/element-hq/element-x-android/pull/4285
* runQualityChecks task shouldn't fail fast by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4309
* Get library ComposablePreviewScanner from maven and update to the latest version by @bmarty in https://github.com/element-hq/element-x-android/pull/4327
### Dependency upgrades
* Update dependency com.posthog:posthog-android to v3.11.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4230
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.78 by @renovate in https://github.com/element-hq/element-x-android/pull/4234
* Update dependency org.maplibre.gl:android-sdk to v11.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4245
* fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4258
* fix(deps): update dependency io.sentry:sentry-android to v8.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4262
* fix(deps): update telephoto to v0.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4270
* fix(deps): update dependency com.google.firebase:firebase-bom to v33.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4249
* chore(deps): update danger/danger-js action to v12.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4259
* fix(deps): update android.gradle.plugin to v8.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4263
* chore(deps): update plugin dependencycheck to v12.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4272
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25 by @renovate in https://github.com/element-hq/element-x-android/pull/4273
* fix(deps): update dependency androidx.compose:compose-bom to v2025.02.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4261
* fix(deps): update kotlin to v2.1.10-1.0.30 by @renovate in https://github.com/element-hq/element-x-android/pull/4265
* fix(deps): update dependency io.github.zxing-cpp:android to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4282
* fix(deps): update firebaseappdistribution to v5.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4246
* fix(deps): update dependencyanalysis to v2.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4251
* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4283
* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4287
* fix(deps): update dependencyanalysis to v2.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4288
* fix(deps): update dependencyanalysis to v2.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4295
* Upgrade SDK version to 25.02.26 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4305
* fix(deps): update kotlinpoet to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4304
* Update compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4319
* fix(deps): update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4324
* fix(deps): update activity to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4321
* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.0 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4325
* fix(deps): update dependency androidx.constraintlayout:constraintlayout to v2.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4322
* fix(deps): update dependency io.sentry:sentry-android to v8.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4316
* fix(deps): update dependency com.posthog:posthog-android to v3.11.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4313
* fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4299
* chore(deps): update plugin detekt to v1.23.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4292
### Others
* Update incoming call notification content to "📹 Incoming call" by @bmarty in https://github.com/element-hq/element-x-android/pull/4231
* Display a bottom sheet to let user confirm the DM creation by @bmarty in https://github.com/element-hq/element-x-android/pull/4233
* Open chat links in regular browser tabs by @cbs228 in https://github.com/element-hq/element-x-android/pull/4198
* Theme override by @bmarty in https://github.com/element-hq/element-x-android/pull/4226
* Allow user certificate in production builds. by @bmarty in https://github.com/element-hq/element-x-android/pull/4275
* Replace Material icons with Compound icons wherever it's possible by @bmarty in https://github.com/element-hq/element-x-android/pull/4323
## New Contributors
* @cbs228 made their first contribution in https://github.com/element-hq/element-x-android/pull/4198
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.02.0...v25.03.0
Changes in Element X v25.02.0 (2025-02-04)
==========================================
<!-- Release notes generated using configuration in .github/release.yml at v25.02.0 --> <!-- Release notes generated using configuration in .github/release.yml at v25.02.0 -->
## What's Changed ## What's Changed

View file

@ -5,12 +5,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(DelicateCoilApi::class)
package io.element.android.appnav package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import coil.Coil import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -69,7 +72,7 @@ class LoggedInAppScopeFlowNode @AssistedInject constructor(
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
Coil.setImageLoader(imageLoaderHolder.get(inputs.matrixClient)) SingletonImageLoader.setUnsafe(imageLoaderHolder.get(inputs.matrixClient))
}, },
) )
} }

View file

@ -10,34 +10,33 @@ package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor( class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
) { ) {
private var observingJob: Job? = null private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom }
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch { observingJob = roomMembershipObserver.updates
displayLeftRoomMessage .filter { !it.isUserInRoom }
.filter { it } .onEach {
.onEach { when (it.change) {
displayMessage(CommonStrings.common_current_user_left_room) MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit
} }
.launchIn(this) }
} .launchIn(coroutineScope)
} }
fun stopObserving() { fun stopObserving() {

View file

@ -262,10 +262,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onRoomDirectorySearchClick() {
backstack.push(NavTarget.RoomDirectorySearch)
}
override fun onLogoutForNativeSlidingSyncMigrationNeeded() { override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded) backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
} }
@ -360,6 +356,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) { override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames)) backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
} }
override fun onOpenRoomDirectory() {
backstack.push(NavTarget.RoomDirectorySearch)
}
} }
createRoomEntryPoint createRoomEntryPoint

View file

@ -5,12 +5,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(DelicateCoilApi::class)
package io.element.android.appnav package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import coil.Coil import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -55,7 +58,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
Coil.setImageLoader(notLoggedInImageLoaderFactory) SingletonImageLoader.setUnsafe(notLoggedInImageLoaderFactory.newImageLoader())
}, },
) )
} }

View file

@ -0,0 +1,2 @@
Main changes in this version: Event cache / Join room by address.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -33,6 +33,9 @@ data class WidgetMessage(
@SerialName("im.vector.hangup") @SerialName("im.vector.hangup")
HangUp, HangUp,
@SerialName("io.element.close")
Close,
@SerialName("send_event") @SerialName("send_event")
SendEvent, SendEvent,
} }

View file

@ -135,7 +135,7 @@ class CallScreenPresenter @AssistedInject constructor(
val parsedMessage = parseMessage(it) val parsedMessage = parseMessage(it)
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) { if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
if (parsedMessage.action == WidgetMessage.Action.HangUp) { if (parsedMessage.action == WidgetMessage.Action.Close) {
close(callWidgetDriver.value, navigator) close(callWidgetDriver.value, navigator)
} else if (parsedMessage.action == WidgetMessage.Action.SendEvent) { } else if (parsedMessage.action == WidgetMessage.Action.SendEvent) {
// This event is received when a member joins the call, the first one will be the current one // This event is received when a member joins the call, the first one will be the current one

View file

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Pågående samtale"</string>
<string name="call_foreground_service_message_android">"Trykk for å gå tilbake til samtalen"</string>
<string name="call_foreground_service_title_android">"☎️ Samtale pågår"</string>
<string name="screen_incoming_call_subtitle_android">"Innkommende Element-anrop"</string> <string name="screen_incoming_call_subtitle_android">"Innkommende Element-anrop"</string>
</resources> </resources>

View file

@ -9,7 +9,7 @@ package io.element.android.features.call.notifications
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import coil.ImageLoader import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID

View file

@ -174,7 +174,7 @@ class CallScreenPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
@ -191,7 +191,7 @@ class CallScreenPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""") messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run // Let background coroutines run
runCurrent() runCurrent()

View file

@ -22,5 +22,6 @@ interface CreateRoomEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
fun onOpenRoomDirectory()
} }
} }

View file

@ -21,15 +21,19 @@ interface CreateRoomNavigator : Plugin {
fun onCreateNewRoom() fun onCreateNewRoom()
fun onShowJoinRoomByAddress() fun onShowJoinRoomByAddress()
fun onDismissJoinRoomByAddress() fun onDismissJoinRoomByAddress()
fun onOpenRoomDirectory()
} }
class DefaultCreateRoomNavigator( class DefaultCreateRoomNavigator(
private val backstack: BackStack<NavTarget>, private val backstack: BackStack<NavTarget>,
private val overlay: Overlay<NavTarget>, private val overlay: Overlay<NavTarget>,
private val openRoom: (RoomIdOrAlias, List<String>) -> Unit, private val openRoom: (RoomIdOrAlias, List<String>) -> Unit,
private val openRoomDirectory: () -> Unit,
) : CreateRoomNavigator { ) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = openRoom(roomIdOrAlias, serverNames) override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = openRoom(roomIdOrAlias, serverNames)
override fun onOpenRoomDirectory() = openRoomDirectory()
override fun onCreateNewRoom() { override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom) backstack.push(NavTarget.NewRoom)
} }

View file

@ -60,6 +60,9 @@ class CreateRoomFlowNode @AssistedInject constructor(
overlay = overlay, overlay = overlay,
openRoom = { roomIdOrAlias, viaServers -> openRoom = { roomIdOrAlias, viaServers ->
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) } plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) }
},
openRoomDirectory = {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoomDirectory() }
} }
) )

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
@ -175,6 +176,7 @@ class ConfigureRoomPresenter @Inject constructor(
isEncrypted = config.roomVisibility is RoomVisibilityState.Private, isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false, isDirect = false,
visibility = RoomVisibility.Private, visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
preset = RoomPreset.PRIVATE_CHAT, preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId }, invite = config.invites.map { it.userId },
avatar = avatarUrl, avatar = avatarUrl,

View file

@ -55,7 +55,8 @@ class CreateRoomRootNode @AssistedInject constructor(
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList()) navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
}, },
onJoinByAddressClick = navigator::onShowJoinRoomByAddress, onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
onInviteFriendsClick = { invitePeople(activity) } onInviteFriendsClick = { invitePeople(activity) },
onRoomDirectorySearchClick = navigator::onOpenRoomDirectory
) )
} }

View file

@ -9,6 +9,8 @@ package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -20,6 +22,8 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenterArg
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,6 +35,7 @@ class CreateRoomRootPresenter @Inject constructor(
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
private val startDMAction: StartDMAction, private val startDMAction: StartDMAction,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
UserListPresenterArgs( UserListPresenterArgs(
@ -47,6 +52,8 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch { is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
@ -64,6 +71,7 @@ class CreateRoomRootPresenter @Inject constructor(
applicationName = buildMeta.applicationName, applicationName = buildMeta.applicationName,
userListState = userListState, userListState = userListState,
startDmAction = startDmActionState.value, startDmAction = startDmActionState.value,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -15,5 +15,6 @@ data class CreateRoomRootState(
val applicationName: String, val applicationName: String,
val userListState: UserListState, val userListState: UserListState,
val startDmAction: AsyncAction<RoomId>, val startDmAction: AsyncAction<RoomId>,
val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (CreateRoomRootEvents) -> Unit, val eventSink: (CreateRoomRootEvents) -> Unit,
) )

View file

@ -53,6 +53,9 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
aCreateRoomRootState( aCreateRoomRootState(
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()), startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
), ),
aCreateRoomRootState(
isRoomDirectorySearchEnabled = true,
),
) )
} }
@ -60,10 +63,12 @@ fun aCreateRoomRootState(
applicationName: String = "Element X Preview", applicationName: String = "Element X Preview",
userListState: UserListState = aUserListState(), userListState: UserListState = aUserListState(),
startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
isRoomDirectorySearchEnabled: Boolean = false,
eventSink: (CreateRoomRootEvents) -> Unit = {}, eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState( ) = CreateRoomRootState(
applicationName = applicationName, applicationName = applicationName,
userListState = userListState, userListState = userListState,
startDmAction = startDmAction, startDmAction = startDmAction,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -56,6 +56,7 @@ fun CreateRoomRootView(
onOpenDM: (RoomId) -> Unit, onOpenDM: (RoomId) -> Unit,
onInviteFriendsClick: () -> Unit, onInviteFriendsClick: () -> Unit,
onJoinByAddressClick: () -> Unit, onJoinByAddressClick: () -> Unit,
onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -91,6 +92,7 @@ fun CreateRoomRootView(
onNewRoomClick = onNewRoomClick, onNewRoomClick = onNewRoomClick,
onInvitePeopleClick = onInviteFriendsClick, onInvitePeopleClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinByAddressClick, onJoinByAddressClick = onJoinByAddressClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
onDmClick = onOpenDM, onDmClick = onOpenDM,
) )
} }
@ -156,6 +158,7 @@ private fun CreateRoomActionButtonsList(
onNewRoomClick: () -> Unit, onNewRoomClick: () -> Unit,
onInvitePeopleClick: () -> Unit, onInvitePeopleClick: () -> Unit,
onJoinByAddressClick: () -> Unit, onJoinByAddressClick: () -> Unit,
onRoomDirectorySearchClick: () -> Unit,
onDmClick: (RoomId) -> Unit, onDmClick: (RoomId) -> Unit,
) { ) {
LazyColumn { LazyColumn {
@ -166,6 +169,15 @@ private fun CreateRoomActionButtonsList(
onClick = onNewRoomClick, onClick = onNewRoomClick,
) )
} }
if (state.isRoomDirectorySearchEnabled) {
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_list_bulleted,
text = stringResource(id = R.string.screen_room_directory_search_title),
onClick = onRoomDirectorySearchClick,
)
}
}
item { item {
CreateRoomActionButton( CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_share_android, iconRes = CompoundDrawables.ic_compound_share_android,
@ -242,5 +254,6 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv
onOpenDM = {}, onOpenDM = {},
onJoinByAddressClick = {}, onJoinByAddressClick = {},
onInviteFriendsClick = {}, onInviteFriendsClick = {},
onRoomDirectorySearchClick = {},
) )
} }

View file

@ -20,4 +20,10 @@ To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_title">"Vytvořit místnost"</string> <string name="screen_create_room_title">"Vytvořit místnost"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string> <string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string> <string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
<string name="screen_start_chat_join_room_by_address_action">"Vstoupit do místnosti pomocí adresy"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Neplatná adresa"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Zadejte…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Odpovídající místnost nalezena"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Místnost nebyla nalezena"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"např. #room-name:matrix.org"</string>
</resources> </resources>

View file

@ -20,4 +20,10 @@ Sa võid seda jututoa seadistustest alati muuta."</string>
<string name="screen_create_room_title">"Loo jututuba"</string> <string name="screen_create_room_title">"Loo jututuba"</string>
<string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string> <string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string>
<string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string> <string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string>
<string name="screen_start_chat_join_room_by_address_action">"Liitu jututoaga aadressi alusel"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"See pole kehtiv aadress"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Sisene…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Leidsime vastava jututoa"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Jututuba ei leidu"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"nt. #jututoa-nimi:matrix.org"</string>
</resources> </resources>

View file

@ -20,4 +20,10 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string
<string name="screen_create_room_title">"Créer un salon"</string> <string name="screen_create_room_title">"Créer un salon"</string>
<string name="screen_create_room_topic_label">"Sujet (facultatif)"</string> <string name="screen_create_room_topic_label">"Sujet (facultatif)"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string> <string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string>
<string name="screen_start_chat_join_room_by_address_action">"Saisir une adresse de salon"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ce nest pas une adresse valide"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Saisir…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Ce salon existe"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Salon non trouvé"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"ex: #nom-du-salon:matrix.org"</string>
</resources> </resources>

View file

@ -20,4 +20,10 @@ Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string>
<string name="screen_create_room_title">"Vytvoriť miestnosť"</string> <string name="screen_create_room_title">"Vytvoriť miestnosť"</string>
<string name="screen_create_room_topic_label">"Téma (voliteľné)"</string> <string name="screen_create_room_topic_label">"Téma (voliteľné)"</string>
<string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string> <string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string>
<string name="screen_start_chat_join_room_by_address_action">"Pripojte sa do miestnosti podľa adresy"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Neplatná adresa"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Zadajte…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Nájdená zodpovedajúca miestnosť"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Miestnosť sa nenašla"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"napr. #nazov-miestnosti:matrix.org"</string>
</resources> </resources>

View file

@ -14,6 +14,7 @@ Du kan ändra detta när som helst i rumsinställningarna."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om att gå med"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Be om att gå med"</string>
<string name="screen_create_room_room_address_section_footer">"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."</string> <string name="screen_create_room_room_address_section_footer">"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."</string>
<string name="screen_create_room_room_address_section_title">"Rumsadress"</string>
<string name="screen_create_room_room_name_label">"Rumsnamn"</string> <string name="screen_create_room_room_name_label">"Rumsnamn"</string>
<string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string> <string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string>
<string name="screen_create_room_title">"Skapa ett rum"</string> <string name="screen_create_room_title">"Skapa ett rum"</string>

View file

@ -19,6 +19,7 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</string> <string name="screen_create_room_room_visibility_section_title">"Room visibility"</string>
<string name="screen_create_room_title">"Create a room"</string> <string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string> <string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_room_directory_search_title">"Room directory"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string> <string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string> <string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string> <string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string>

View file

@ -15,7 +15,8 @@ class FakeCreateRoomNavigator(
private val createNewRoomLambda: () -> Unit = {}, private val createNewRoomLambda: () -> Unit = {},
private val showJoinRoomByAddressLambda: () -> Unit = {}, private val showJoinRoomByAddressLambda: () -> Unit = {},
private val dismissJoinRoomByAddressLambda: () -> Unit = {}, private val dismissJoinRoomByAddressLambda: () -> Unit = {},
) : CreateRoomNavigator { private val openRoomDirectoryLambda: () -> Unit = {},
) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) { override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
openRoomLambda(roomIdOrAlias, serverNames) openRoomLambda(roomIdOrAlias, serverNames)
} }
@ -31,4 +32,8 @@ class FakeCreateRoomNavigator(
override fun onDismissJoinRoomByAddress() { override fun onDismissJoinRoomByAddress() {
dismissJoinRoomByAddressLambda() dismissJoinRoomByAddressLambda()
} }
override fun onOpenRoomDirectory() {
openRoomDirectoryLambda()
}
} }

View file

@ -19,6 +19,8 @@ import io.element.android.features.createroom.impl.userlist.FakeUserListPresente
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -163,14 +165,34 @@ class CreateRoomRootPresenterTest {
} }
} }
@Test
fun `present - room directory search`() = runTest {
val presenter = createCreateRoomRootPresenter(isRoomDirectorySearchEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().let { state ->
assertThat(state.isRoomDirectorySearchEnabled).isTrue()
}
}
}
private fun createCreateRoomRootPresenter( private fun createCreateRoomRootPresenter(
startDMAction: StartDMAction = FakeStartDMAction(), startDMAction: StartDMAction = FakeStartDMAction(),
isRoomDirectorySearchEnabled: Boolean = false,
): CreateRoomRootPresenter { ): CreateRoomRootPresenter {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.RoomDirectorySearch.key to isRoomDirectorySearchEnabled,
),
)
return CreateRoomRootPresenter( return CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()), presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
userRepository = FakeUserRepository(), userRepository = FakeUserRepository(),
userListDataStore = UserListDataStore(), userListDataStore = UserListDataStore(),
startDMAction = startDMAction, startDMAction = startDMAction,
featureFlagService = featureFlagService,
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
) )
} }

View file

@ -116,6 +116,21 @@ class CreateRoomRootViewTest {
rule.clickOn(R.string.screen_start_chat_join_room_by_address_action) rule.clickOn(R.string.screen_start_chat_join_room_by_address_action)
} }
} }
@Test
fun `clicking on room directory invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
isRoomDirectorySearchEnabled = true
),
onRoomDirectorySearchClick = it
)
rule.clickOn(R.string.screen_room_directory_search_title)
}
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
@ -125,6 +140,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(), onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(), onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
CreateRoomRootView( CreateRoomRootView(
@ -133,7 +149,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onNewRoomClick = onNewRoomClick, onNewRoomClick = onNewRoomClick,
onOpenDM = onOpenDM, onOpenDM = onOpenDM,
onInviteFriendsClick = onInviteFriendsClick, onInviteFriendsClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinRoomByAddressClick onJoinByAddressClick = onJoinRoomByAddressClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
) )
} }
} }

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Du kan endre innstillingene dine senere."</string>
<string name="screen_notification_optin_title">"Tillat varslinger og gå aldri glipp av en melding"</string>
<string name="screen_welcome_bullet_1">"Samtaler, avstemninger, søk og mer vil bli lagt til senere i år."</string>
<string name="screen_welcome_bullet_2">"Meldingshistorikk for krypterte rom er ikke tilgjengelig ennå."</string>
<string name="screen_welcome_bullet_3">"Vi vil gjerne høre fra deg, så la oss få vite hva du synes via innstillingssiden."</string>
<string name="screen_welcome_button">"Kom igjen!"</string>
<string name="screen_welcome_subtitle">"Her er hva du trenger å vite:"</string>
<string name="screen_welcome_title">"Velkommen til %1$s!"</string>
</resources>

View file

@ -8,6 +8,6 @@
package io.element.android.features.invite.api.response package io.element.android.features.invite.api.response
interface AcceptDeclineInviteEvents { interface AcceptDeclineInviteEvents {
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class AcceptInvite(val invite: InviteData?) : AcceptDeclineInviteEvents
data class DeclineInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class DeclineInvite(val invite: InviteData?, val blockUser: Boolean = false) : AcceptDeclineInviteEvents
} }

View file

@ -10,6 +10,7 @@ package io.element.android.features.invite.api.response
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> { open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> {
override val values: Sequence<AcceptDeclineInviteState> override val values: Sequence<AcceptDeclineInviteState>
@ -17,12 +18,20 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
anAcceptDeclineInviteState(), anAcceptDeclineInviteState(),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = true, roomName = "Alice") InviteData(roomId = RoomId("!room:matrix.org"), isDm = true, roomName = "Alice", senderId = UserId("@alice:matrix.org")),
blockUser = false,
), ),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = false, roomName = "Some room") InviteData(roomId = RoomId("!room:matrix.org"), isDm = false, roomName = "Some room", senderId = UserId("@alice:matrix.org")),
blockUser = false,
),
),
anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite(
InviteData(roomId = RoomId("!room:matrix.org"), isDm = true, roomName = "Alice", senderId = UserId("@alice:matrix.org")),
blockUser = true,
), ),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(

View file

@ -11,4 +11,5 @@ import io.element.android.libraries.architecture.AsyncAction
data class ConfirmingDeclineInvite( data class ConfirmingDeclineInvite(
val inviteData: InviteData, val inviteData: InviteData,
val blockUser: Boolean,
) : AsyncAction.Confirming ) : AsyncAction.Confirming

View file

@ -8,8 +8,10 @@
package io.element.android.features.invite.api.response package io.element.android.features.invite.api.response
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
data class InviteData( data class InviteData(
val senderId: UserId,
val roomId: RoomId, val roomId: RoomId,
val roomName: String, val roomName: String,
val isDm: Boolean, val isDm: Boolean,

View file

@ -16,6 +16,7 @@ import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -43,15 +44,34 @@ class AcceptDeclineInvitePresenter @Inject constructor(
fun handleEvents(event: AcceptDeclineInviteEvents) { fun handleEvents(event: AcceptDeclineInviteEvents) {
when (event) { when (event) {
is AcceptDeclineInviteEvents.AcceptInvite -> { is AcceptDeclineInviteEvents.AcceptInvite -> {
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) val inviteData = event.invite
if (inviteData == null) {
acceptedAction.value = AsyncAction.Failure(InvalidDataException())
} else {
localCoroutineScope.acceptInvite(inviteData.roomId, acceptedAction)
}
} }
is AcceptDeclineInviteEvents.DeclineInvite -> { is AcceptDeclineInviteEvents.DeclineInvite -> {
declinedAction.value = ConfirmingDeclineInvite(event.invite) val inviteData = event.invite
if (inviteData == null) {
declinedAction.value = AsyncAction.Failure(InvalidDataException())
} else {
declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser)
}
} }
is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> { is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> {
localCoroutineScope.declineInvite(event.roomId, declinedAction) when (val declinedActionValue = declinedAction.value) {
is ConfirmingDeclineInvite -> {
localCoroutineScope.declineInvite(
inviteData = declinedActionValue.inviteData,
declinedAction = declinedAction,
blockUser = declinedActionValue.blockUser,
)
}
else -> Unit
}
} }
is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> { is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
@ -92,13 +112,20 @@ class AcceptDeclineInvitePresenter @Inject constructor(
} }
} }
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<AsyncAction<RoomId>>) = launch { private fun CoroutineScope.declineInvite(
inviteData: InviteData,
blockUser: Boolean,
declinedAction: MutableState<AsyncAction<RoomId>>,
) = launch {
suspend { suspend {
client.getPendingRoom(roomId)?.use { client.getPendingRoom(inviteData.roomId)?.use {
it.leave().getOrThrow() it.leave().getOrThrow()
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
roomId if (blockUser) {
client.ignoreUser(inviteData.senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }
} }

View file

@ -50,8 +50,9 @@ fun AcceptDeclineInviteView(
if (confirming is ConfirmingDeclineInvite) { if (confirming is ConfirmingDeclineInvite) {
DeclineConfirmationDialog( DeclineConfirmationDialog(
invite = confirming.inviteData, invite = confirming.inviteData,
blockUser = confirming.blockUser,
onConfirmClick = { onConfirmClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(confirming.inviteData.roomId)) state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite)
}, },
onDismissClick = { onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)
@ -66,29 +67,35 @@ fun AcceptDeclineInviteView(
@Composable @Composable
private fun DeclineConfirmationDialog( private fun DeclineConfirmationDialog(
invite: InviteData, invite: InviteData,
blockUser: Boolean,
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
onDismissClick: () -> Unit, onDismissClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contentResource = if (invite.isDm) { val senderId = invite.senderId.value
R.string.screen_invites_decline_direct_chat_message val content = when {
} else { blockUser -> stringResource(R.string.screen_join_room_decline_and_block_alert_message, senderId)
R.string.screen_invites_decline_chat_message invite.isDm -> stringResource(R.string.screen_invites_decline_direct_chat_message, invite.roomName)
else -> stringResource(R.string.screen_invites_decline_chat_message, invite.roomName)
} }
val title = when {
val titleResource = if (invite.isDm) { blockUser -> stringResource(R.string.screen_join_room_decline_and_block_alert_title)
R.string.screen_invites_decline_direct_chat_title invite.isDm -> stringResource(R.string.screen_invites_decline_direct_chat_title)
} else { else -> stringResource(R.string.screen_invites_decline_chat_title)
R.string.screen_invites_decline_chat_title }
val submitText = if (blockUser) {
stringResource(R.string.screen_join_room_decline_and_block_alert_confirmation)
} else {
stringResource(CommonStrings.action_decline)
} }
ConfirmationDialog( ConfirmationDialog(
modifier = modifier, modifier = modifier,
content = stringResource(contentResource, invite.roomName), content = content,
title = stringResource(titleResource), title = title,
submitText = stringResource(CommonStrings.action_decline), submitText = submitText,
cancelText = stringResource(CommonStrings.action_cancel), cancelText = stringResource(CommonStrings.action_cancel),
onSubmitClick = onConfirmClick, onSubmitClick = onConfirmClick,
destructiveSubmit = blockUser,
onDismiss = onDismissClick, onDismiss = onDismissClick,
) )
} }

View file

@ -8,10 +8,9 @@
package io.element.android.features.invite.impl.response package io.element.android.features.invite.impl.response
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data class ConfirmDeclineInvite(val roomId: RoomId) : InternalAcceptDeclineInviteEvents data object ConfirmDeclineInvite : InternalAcceptDeclineInviteEvents
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents data object DismissAcceptError : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents data object DismissDeclineError : InternalAcceptDeclineInviteEvents

View file

@ -0,0 +1,10 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.impl.response
class InvalidDataException : Exception()

View file

@ -6,4 +6,8 @@
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string> <string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string> <string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
<string name="screen_join_room_decline_and_block_alert_confirmation">"Yes, decline &amp; block"</string>
<string name="screen_join_room_decline_and_block_alert_message">"Are you sure you want to decline the invite to join this room? This will also prevent %1$s from contacting you or inviting you to rooms."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Decline invite &amp; block"</string>
<string name="screen_join_room_decline_and_block_button_title">"Decline and block"</string>
</resources> </resources>

View file

@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomPreview import io.element.android.libraries.matrix.test.room.FakeRoomPreview
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
@ -61,7 +63,7 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.CancelDeclineInvite InternalAcceptDeclineInviteEvents.CancelDeclineInvite
) )
@ -91,9 +93,9 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId) InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
@ -139,9 +141,9 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId) InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
@ -156,6 +158,80 @@ class AcceptDeclineInvitePresenterTest {
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
} }
@Test
fun `present - declining invite with block success flow`() = runTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ ->
Result.success(Unit)
}
val fakeNotificationCleaner = FakeNotificationCleaner(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
val declineInviteSuccess = lambdaRecorder { -> Result.success(Unit) }
val ignoreUserSuccess = lambdaRecorder { _: UserId -> Result.success(Unit) }
val client = FakeMatrixClient(
getRoomPreviewResult = { _, _ ->
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
},
ignoreUserResult = ignoreUserSuccess
)
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = true)
)
}
awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, true))
state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
)
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
}
cancelAndConsumeRemainingEvents()
}
declineInviteSuccess.assertions().isCalledOnce()
ignoreUserSuccess.assertions().isCalledOnce().with(value(A_USER_ID))
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
@Test
fun `present - declining invite with block error flow`() = runTest {
val declineInviteFailure = lambdaRecorder { ->
Result.failure<Unit>(RuntimeException("Failed to leave room"))
}
val client = FakeMatrixClient(
getRoomPreviewResult = { _, _ ->
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
val presenter = createAcceptDeclineInvitePresenter(client = client)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = true)
)
}
awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, true))
state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
)
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
}
}
@Test @Test
fun `present - accepting invite error flow`() = runTest { fun `present - accepting invite error flow`() = runTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger -> val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
@ -237,12 +313,14 @@ class AcceptDeclineInvitePresenterTest {
private fun anInviteData( private fun anInviteData(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME, name: String = A_ROOM_NAME,
isDm: Boolean = false isDm: Boolean = false,
senderId: UserId = A_USER_ID,
): InviteData { ): InviteData {
return InviteData( return InviteData(
roomId = roomId, roomId = roomId,
roomName = name, roomName = name,
isDm = isDm isDm = isDm,
senderId = senderId,
) )
} }

View file

@ -17,5 +17,5 @@ sealed interface JoinRoomEvents {
data class UpdateKnockMessage(val message: String) : JoinRoomEvents data class UpdateKnockMessage(val message: String) : JoinRoomEvents
data object ClearActionStates : JoinRoomEvents data object ClearActionStates : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents data class DeclineInvite(val blockUser: Boolean) : JoinRoomEvents
} }

View file

@ -152,15 +152,15 @@ class JoinRoomPresenter @AssistedInject constructor(
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage) is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage)
JoinRoomEvents.AcceptInvite -> { JoinRoomEvents.AcceptInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData()
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvents.AcceptInvite(inviteData)
) )
} }
JoinRoomEvents.DeclineInvite -> { is JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData()
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(invite = inviteData, blockUser = event.blockUser)
) )
} }
is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction) is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction)
@ -314,12 +314,19 @@ private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
@VisibleForTesting @VisibleForTesting
internal fun ContentState.toInviteData(): InviteData? { internal fun ContentState.toInviteData(): InviteData? {
return when (this) { return when (this) {
is ContentState.Loaded -> InviteData( is ContentState.Loaded -> {
roomId = roomId, if (joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited && joinAuthorisationStatus.inviteSender != null) {
// Note: name should not be null at this point, but use Id just in case... InviteData(
roomName = name ?: roomId.value, roomId = roomId,
isDm = isDm // Note: name should not be null at this point, but use Id just in case...
) roomName = name ?: roomId.value,
senderId = joinAuthorisationStatus.inviteSender.userId,
isDm = isDm
)
} else {
null
}
}
else -> null else -> null
} }
} }

View file

@ -63,6 +63,7 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
@ -105,8 +106,8 @@ fun JoinRoomView(
onAcceptInvite = { onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite) state.eventSink(JoinRoomEvents.AcceptInvite)
}, },
onDeclineInvite = { onDeclineInvite = { blockUser ->
state.eventSink(JoinRoomEvents.DeclineInvite) state.eventSink(JoinRoomEvents.DeclineInvite(blockUser))
}, },
onJoinRoom = { onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvents.JoinRoom)
@ -183,7 +184,7 @@ fun JoinRoomView(
private fun JoinRoomFooter( private fun JoinRoomFooter(
joinAuthorisationStatus: JoinAuthorisationStatus, joinAuthorisationStatus: JoinAuthorisationStatus,
onAcceptInvite: () -> Unit, onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit, onDeclineInvite: (Boolean) -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit, onKnockRoom: () -> Unit,
onCancelKnock: () -> Unit, onCancelKnock: () -> Unit,
@ -193,23 +194,32 @@ private fun JoinRoomFooter(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp) .padding(top = 8.dp)
) { ) {
when (joinAuthorisationStatus) { when (joinAuthorisationStatus) {
is JoinAuthorisationStatus.IsInvited -> { is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) { Column {
OutlinedButton( ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
text = stringResource(CommonStrings.action_decline), OutlinedButton(
onClick = onDeclineInvite, text = stringResource(CommonStrings.action_decline),
modifier = Modifier.weight(1f), onClick = { onDeclineInvite(false) },
size = ButtonSize.LargeLowPadding, modifier = Modifier.weight(1f),
) size = ButtonSize.LargeLowPadding,
Button( )
text = stringResource(CommonStrings.action_accept), Button(
onClick = onAcceptInvite, text = stringResource(CommonStrings.action_accept),
modifier = Modifier.weight(1f), onClick = onAcceptInvite,
size = ButtonSize.LargeLowPadding, modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
}
Spacer(modifier = Modifier.height(24.dp))
TextButton(
text = stringResource(R.string.screen_join_room_decline_and_block_button_title),
onClick = { onDeclineInvite(true) },
modifier = Modifier.fillMaxWidth(),
destructive = true
) )
} }
} }
@ -372,12 +382,19 @@ private fun JoinRoomContent(
IsKnockedLoadedContent() IsKnockedLoadedContent()
} }
else -> { else -> {
DefaultLoadedContent( Column(horizontalAlignment = Alignment.CenterHorizontally) {
modifier = Modifier.verticalScroll(rememberScrollState()), val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
contentState = contentState, if (inviteSender != null) {
knockMessage = knockMessage, InviteSenderView(inviteSender = inviteSender)
onKnockMessageUpdate = onKnockMessageUpdate Spacer(modifier = Modifier.height(32.dp))
) }
DefaultLoadedContent(
modifier = Modifier.verticalScroll(rememberScrollState()),
contentState = contentState,
knockMessage = knockMessage,
onKnockMessageUpdate = onKnockMessageUpdate
)
}
} }
} }
} }
@ -440,8 +457,8 @@ private fun IncompleteContent(
private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) { private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) {
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
@ -487,10 +504,6 @@ private fun DefaultLoadedContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender)
}
RoomPreviewDescriptionAtom(contentState.topic ?: "") RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) { if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))

View file

@ -4,6 +4,8 @@
<string name="screen_join_room_cancel_knock_alert_confirmation">"Ja, avbryt"</string> <string name="screen_join_room_cancel_knock_alert_confirmation">"Ja, avbryt"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Är du säker på att du vill avbryta din begäran om att gå med i det här rummet?"</string> <string name="screen_join_room_cancel_knock_alert_description">"Är du säker på att du vill avbryta din begäran om att gå med i det här rummet?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Avbryt begäran om att gå med"</string> <string name="screen_join_room_cancel_knock_alert_title">"Avbryt begäran om att gå med"</string>
<string name="screen_join_room_forget_action">"Glöm det här rummet"</string>
<string name="screen_join_room_invite_required_message">"Du behöver en inbjudan för att gå med i detta rum"</string>
<string name="screen_join_room_join_action">"Gå med i rummet"</string> <string name="screen_join_room_join_action">"Gå med i rummet"</string>
<string name="screen_join_room_knock_action">"Knacka för att gå med"</string> <string name="screen_join_room_knock_action">"Knacka för att gå med"</string>
<string name="screen_join_room_knock_message_description">"Meddelande (valfritt)"</string> <string name="screen_join_room_knock_message_description">"Meddelande (valfritt)"</string>

View file

@ -167,9 +167,9 @@ class JoinRoomPresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.AcceptInvite) state.eventSink(JoinRoomEvents.AcceptInvite)
state.eventSink(JoinRoomEvents.DeclineInvite) state.eventSink(JoinRoomEvents.DeclineInvite(false))
val inviteData = state.contentState.toInviteData()!! val inviteData = state.contentState.toInviteData()
assert(eventSinkRecorder) assert(eventSinkRecorder)
.isCalledExactly(2) .isCalledExactly(2)

View file

@ -139,7 +139,7 @@ class JoinRoomViewTest {
} }
@Test @Test
fun `clicking on Accept invitation IsInvited room emits the expected Event`() { fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( rule.setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@ -152,7 +152,7 @@ class JoinRoomViewTest {
} }
@Test @Test
fun `clicking on Decline invitation on IsInvited room emits the expected Event`() { fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( rule.setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@ -161,7 +161,20 @@ class JoinRoomViewTest {
), ),
) )
rule.clickOn(CommonStrings.action_decline) rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite) eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(false))
}
@Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_decline_and_block_button_title)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(true))
} }
@Test @Test

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -22,9 +23,10 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil3.Extras
import coil.compose.rememberAsyncImagePainter import coil3.compose.AsyncImagePainter
import coil.request.ImageRequest import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.StaticMapUrlBuilder
@ -33,7 +35,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import timber.log.Timber
/** /**
* Shows a static map image downloaded via a third party service's static maps API. * Shows a static map image downloaded via a third party service's static maps API.
@ -75,14 +76,15 @@ fun StaticMapView(
) )
) )
.size(width = constraints.maxWidth, height = constraints.maxHeight) .size(width = constraints.maxWidth, height = constraints.maxHeight)
.setParameter("retry_hash", retryHash, memoryCacheKey = null) .apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build() .build()
}.apply {
Timber.d("Static map image request: ${this?.data}")
} }
) )
if (painter.state is AsyncImagePainter.State.Success) { val collectedState = painter.state.collectAsState()
if (collectedState.value is AsyncImagePainter.State.Success) {
Image( Image(
painter = painter, painter = painter,
contentDescription = contentDescription, contentDescription = contentDescription,
@ -100,7 +102,7 @@ fun StaticMapView(
) )
} else { } else {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = painter.state is AsyncImagePainter.State.Loading, showProgress = collectedState.value.isLoading(),
contentDescription = contentDescription, contentDescription = contentDescription,
width = maxWidth, width = maxWidth,
height = maxHeight, height = maxHeight,
@ -110,6 +112,11 @@ fun StaticMapView(
} }
} }
private fun AsyncImagePainter.State.isLoading(): Boolean {
return this is AsyncImagePainter.State.Empty ||
this is AsyncImagePainter.State.Loading
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun StaticMapViewPreview() = ElementPreview { internal fun StaticMapViewPreview() = ElementPreview {

View file

@ -11,16 +11,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.lockscreen.impl.R import io.element.android.features.lockscreen.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun LockScreenSettingsView( fun LockScreenSettingsView(
@ -35,15 +36,19 @@ fun LockScreenSettingsView(
modifier = modifier modifier = modifier
) { ) {
PreferenceCategory(showTopDivider = false) { PreferenceCategory(showTopDivider = false) {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_app_lock_settings_change_pin), headlineContent = {
onClick = onChangePinClick Text(stringResource(id = R.string.screen_app_lock_settings_change_pin))
},
onClick = onChangePinClick,
) )
PreferenceDivider() PreferenceDivider()
if (state.showRemovePinOption) { if (state.showRemovePinOption) {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin), headlineContent = {
tintColor = ElementTheme.colors.textCriticalPrimary, Text(stringResource(id = R.string.screen_app_lock_settings_remove_pin))
},
style = ListItemStyle.Destructive,
onClick = { onClick = {
state.eventSink(LockScreenSettingsEvents.OnRemovePin) state.eventSink(LockScreenSettingsEvents.OnRemovePin)
} }

View file

@ -2,12 +2,14 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Bytt kontotilbyder"</string> <string name="screen_account_provider_change">"Bytt kontotilbyder"</string>
<string name="screen_account_provider_form_hint">"Hjemmeserveradresse"</string> <string name="screen_account_provider_form_hint">"Hjemmeserveradresse"</string>
<string name="screen_account_provider_form_notice">"Skriv inn et søkeord eller en domeneadresse."</string>
<string name="screen_account_provider_form_subtitle">"Søk etter et selskap, fellesskap eller privat server."</string> <string name="screen_account_provider_form_subtitle">"Søk etter et selskap, fellesskap eller privat server."</string>
<string name="screen_account_provider_form_title">"Finn en kontoleverandør"</string> <string name="screen_account_provider_form_title">"Finn en kontoleverandør"</string>
<string name="screen_account_provider_signin_subtitle">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string> <string name="screen_account_provider_signin_subtitle">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string>
<string name="screen_account_provider_signin_title">"Du er i ferd med å logge inn på %s"</string> <string name="screen_account_provider_signin_title">"Du er i ferd med å logge inn på %s"</string>
<string name="screen_account_provider_signup_subtitle">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string> <string name="screen_account_provider_signup_subtitle">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string>
<string name="screen_account_provider_signup_title">"Du er i ferd med å opprette en konto på %s"</string> <string name="screen_account_provider_signup_title">"Du er i ferd med å opprette en konto på %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org er en stor, gratis server på det offentlige Matrix-nettverket for sikker, desentralisert kommunikasjon, drevet av Matrix.org Foundation."</string>
<string name="screen_change_account_provider_other">"Annet"</string> <string name="screen_change_account_provider_other">"Annet"</string>
<string name="screen_change_account_provider_subtitle">"Bruk en annen kontotilbyder, for eksempel din egen private server eller en arbeidskonto."</string> <string name="screen_change_account_provider_subtitle">"Bruk en annen kontotilbyder, for eksempel din egen private server eller en arbeidskonto."</string>
<string name="screen_change_account_provider_title">"Bytt kontotilbyder"</string> <string name="screen_change_account_provider_title">"Bytt kontotilbyder"</string>
@ -16,6 +18,7 @@
<string name="screen_change_server_form_header">"URL til hjemmeserver"</string> <string name="screen_change_server_form_header">"URL til hjemmeserver"</string>
<string name="screen_change_server_form_notice">"Du kan bare koble til en eksisterende server som støtter sliding sync. Administrator for din hjemmeserver må konfigurere det. %1$s"</string> <string name="screen_change_server_form_notice">"Du kan bare koble til en eksisterende server som støtter sliding sync. Administrator for din hjemmeserver må konfigurere det. %1$s"</string>
<string name="screen_change_server_subtitle">"Hva er adressen til serveren din?"</string> <string name="screen_change_server_subtitle">"Hva er adressen til serveren din?"</string>
<string name="screen_change_server_title">"Velg din server"</string>
<string name="screen_create_account_title">"Opprett konto"</string> <string name="screen_create_account_title">"Opprett konto"</string>
<string name="screen_login_error_deactivated_account">"Denne kontoen er deaktivert."</string> <string name="screen_login_error_deactivated_account">"Denne kontoen er deaktivert."</string>
<string name="screen_login_error_invalid_credentials">"Feil brukernavn og/eller passord"</string> <string name="screen_login_error_invalid_credentials">"Feil brukernavn og/eller passord"</string>

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction

View file

@ -37,8 +37,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent 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.ATimelineItemEventRow

View file

@ -25,8 +25,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent 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.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.ProtectedView

View file

@ -40,8 +40,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent

View file

@ -50,7 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction

View file

@ -17,14 +17,24 @@
<string name="screen_room_attachment_source_camera_video">"Ta opp video"</string> <string name="screen_room_attachment_source_camera_video">"Ta opp video"</string>
<string name="screen_room_attachment_source_files">"Vedlegg"</string> <string name="screen_room_attachment_source_files">"Vedlegg"</string>
<string name="screen_room_attachment_source_gallery">"Foto- og videobibliotek"</string> <string name="screen_room_attachment_source_gallery">"Foto- og videobibliotek"</string>
<string name="screen_room_attachment_source_location">"Lokasjon"</string>
<string name="screen_room_attachment_source_poll">"Avstemning"</string>
<string name="screen_room_attachment_text_formatting">"Tekstformatering"</string>
<string name="screen_room_encrypted_history_banner">"Meldingshistorikken er for øyeblikket ikke tilgjengelig."</string> <string name="screen_room_encrypted_history_banner">"Meldingshistorikken er for øyeblikket ikke tilgjengelig."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Meldingshistorikk er ikke tilgjengelig i dette rommet. Bekreft denne enheten for å se meldingshistorikken din."</string> <string name="screen_room_encrypted_history_banner_unverified">"Meldingshistorikk er ikke tilgjengelig i dette rommet. Bekreft denne enheten for å se meldingshistorikken din."</string>
<string name="screen_room_invite_again_alert_message">"Vil du invitere dem tilbake?"</string> <string name="screen_room_invite_again_alert_message">"Vil du invitere dem tilbake?"</string>
<string name="screen_room_invite_again_alert_title">"Du er alene i denne chatten"</string> <string name="screen_room_invite_again_alert_title">"Du er alene i denne chatten"</string>
<string name="screen_room_mentions_at_room_title">"Alle"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send igjen"</string> <string name="screen_room_retry_send_menu_send_again_action">"Send igjen"</string>
<string name="screen_room_retry_send_menu_title">"Meldingen din ble ikke sendt"</string> <string name="screen_room_retry_send_menu_title">"Meldingen din ble ikke sendt"</string>
<string name="screen_room_timeline_add_reaction">"Legg til emoji"</string>
<string name="screen_room_timeline_beginning_of_room">"Dette er begynnelsen på %1$s."</string> <string name="screen_room_timeline_beginning_of_room">"Dette er begynnelsen på %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Dette er begynnelsen på denne samtalen."</string> <string name="screen_room_timeline_beginning_of_room_no_name">"Dette er begynnelsen på denne samtalen."</string>
<string name="screen_room_timeline_less_reactions">"Vis mindre"</string>
<string name="screen_room_timeline_message_copied">"Melding kopiert"</string>
<string name="screen_room_timeline_no_permission_to_post">"Du har ikke tillatelse til å legge ut innlegg i dette rommet"</string>
<string name="screen_room_timeline_reactions_show_less">"Vis mindre"</string>
<string name="screen_room_timeline_reactions_show_more">"Vis mer"</string>
<string name="screen_room_timeline_read_marker_title">"Ny"</string> <string name="screen_room_timeline_read_marker_title">"Ny"</string>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d romendring"</item> <item quantity="one">"%1$d romendring"</item>

View file

@ -3,6 +3,7 @@
<string name="screen_onboarding_sign_in_manually">"Logg på manuelt"</string> <string name="screen_onboarding_sign_in_manually">"Logg på manuelt"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Logg inn med QR-kode"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Logg inn med QR-kode"</string>
<string name="screen_onboarding_sign_up">"Opprett konto"</string> <string name="screen_onboarding_sign_up">"Opprett konto"</string>
<string name="screen_onboarding_welcome_message">"Velkommen til den raskeste %1$s noensinne. Superladet for hastighet og enkelhet."</string>
<string name="screen_onboarding_welcome_subtitle">"Velkommen til %1$s. Supercharged, for hastighet og enkelhet."</string> <string name="screen_onboarding_welcome_subtitle">"Velkommen til %1$s. Supercharged, for hastighet og enkelhet."</string>
<string name="screen_onboarding_welcome_title">"Vær i ditt rette element"</string> <string name="screen_onboarding_welcome_title">"Vær i ditt rette element"</string>
</resources> </resources>

View file

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_poll_add_option_btn">"Legg til alternativ"</string>
<string name="screen_create_poll_anonymous_desc">"Vis resultater bare etter at avstemningen er avsluttet"</string>
<string name="screen_create_poll_anonymous_headline">"Skjul stemmer"</string>
<string name="screen_create_poll_answer_hint">"Alternativ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?"</string> <string name="screen_create_poll_cancel_confirmation_content_android">"Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?"</string>
<string name="screen_create_poll_question_desc">"Spørsmål eller emne"</string>
<string name="screen_create_poll_question_hint">"Hva handler avstemningen om?"</string> <string name="screen_create_poll_question_hint">"Hva handler avstemningen om?"</string>
<string name="screen_create_poll_title">"Opprett avstemning"</string> <string name="screen_create_poll_title">"Opprett avstemning"</string>
<string name="screen_edit_poll_delete_confirmation">"Er du sikker på at du vil slette denne avstemningen?"</string> <string name="screen_edit_poll_delete_confirmation">"Er du sikker på at du vil slette denne avstemningen?"</string>

View file

@ -63,6 +63,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
api(projects.features.preferences.api) api(projects.features.preferences.api)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)

View file

@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -31,13 +32,17 @@ fun AboutView(
title = stringResource(id = CommonStrings.common_about) title = stringResource(id = CommonStrings.common_about)
) { ) {
state.elementLegals.forEach { elementLegal -> state.elementLegals.forEach { elementLegal ->
PreferenceText( ListItem(
title = stringResource(id = elementLegal.titleRes), headlineContent = {
Text(stringResource(id = elementLegal.titleRes))
},
onClick = { onElementLegalClick(elementLegal) } onClick = { onElementLegalClick(elementLegal) }
) )
} }
PreferenceText( ListItem(
title = stringResource(id = CommonStrings.common_open_source_licenses), headlineContent = {
Text(stringResource(id = CommonStrings.common_open_source_licenses))
},
onClick = onOpenSourceLicensesClick, onClick = onOpenSourceLicensesClick,
) )
} }

View file

@ -7,23 +7,29 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.featureflag.ui.FeatureListView import io.element.android.libraries.featureflag.ui.FeatureListView
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -57,13 +63,15 @@ fun DeveloperSettingsView(
selectedOption = state.tracingLogLevel.dataOrNull(), selectedOption = state.tracingLogLevel.dataOrNull(),
options = LogLevelItem.entries.toPersistentList(), options = LogLevelItem.entries.toPersistentList(),
onSelectOption = { logLevel -> onSelectOption = { logLevel ->
state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(logLevel)) state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(logLevel))
} }
) )
} }
PreferenceCategory(title = "Showkase") { PreferenceCategory(title = "Showkase") {
PreferenceText( ListItem(
title = "Open Showkase browser", headlineContent = {
Text("Open Showkase browser")
},
onClick = onOpenShowkase onClick = onOpenShowkase
) )
} }
@ -71,17 +79,31 @@ fun DeveloperSettingsView(
state = state.rageshakeState, state = state.rageshakeState,
) )
PreferenceCategory(title = "Crash", showTopDivider = false) { PreferenceCategory(title = "Crash", showTopDivider = false) {
PreferenceText( ListItem(
title = "Crash the app 💥", headlineContent = {
Text("Crash the app 💥")
},
onClick = { error("This crash is a test.") } onClick = { error("This crash is a test.") }
) )
} }
val cache = state.cacheSize val cache = state.cacheSize
PreferenceCategory(title = "Cache", showTopDivider = false) { PreferenceCategory(title = "Cache", showTopDivider = false) {
PreferenceText( ListItem(
title = "Clear cache", headlineContent = {
currentValue = cache.dataOrNull(), Text("Clear cache")
loadingCurrentValue = state.cacheSize.isLoading() || state.clearCacheAction.isLoading(), },
trailingContent = if (state.cacheSize.isLoading() || state.clearCacheAction.isLoading()) {
ListItemContent.Custom {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
strokeWidth = 2.dp
)
}
} else {
ListItemContent.Text(cache.dataOrNull().orEmpty())
},
onClick = { onClick = {
if (state.clearCacheAction.isLoading().not()) { if (state.clearCacheAction.isLoading().not()) {
state.eventSink(DeveloperSettingsEvents.ClearCache) state.eventSink(DeveloperSettingsEvents.ClearCache)

View file

@ -31,10 +31,10 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
@ -109,13 +109,19 @@ private fun NotificationSettingsContentView(
val context = LocalContext.current val context = LocalContext.current
val systemSettings: NotificationSettingsState.AppSettings = state.appSettings val systemSettings: NotificationSettingsState.AppSettings = state.appSettings
if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) {
PreferenceText( ListItem(
icon = CompoundIcons.NotificationsOffSolid(), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.NotificationsOffSolid())),
title = stringResource(id = R.string.screen_notification_settings_system_notifications_turned_off), headlineContent = {
subtitle = stringResource( Text(stringResource(id = R.string.screen_notification_settings_system_notifications_turned_off))
id = R.string.screen_notification_settings_system_notifications_action_required, },
stringResource(id = R.string.screen_notification_settings_system_notifications_action_required_content_link) supportingContent = {
), Text(
stringResource(
id = R.string.screen_notification_settings_system_notifications_action_required,
stringResource(id = R.string.screen_notification_settings_system_notifications_action_required_content_link)
)
)
},
onClick = { onClick = {
context.startNotificationSettingsIntent() context.startNotificationSettingsIntent()
} }
@ -131,10 +137,14 @@ private fun NotificationSettingsContentView(
if (systemSettings.appNotificationsEnabled) { if (systemSettings.appNotificationsEnabled) {
if (!state.fullScreenIntentPermissionsState.permissionGranted) { if (!state.fullScreenIntentPermissionsState.permissionGranted) {
PreferenceCategory { PreferenceCategory {
PreferenceText( ListItem(
icon = CompoundIcons.VoiceCallSolid(), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VoiceCallSolid())),
title = stringResource(id = R.string.full_screen_intent_banner_title), headlineContent = {
subtitle = stringResource(R.string.full_screen_intent_banner_message), Text(stringResource(id = R.string.full_screen_intent_banner_title))
},
supportingContent = {
Text(stringResource(R.string.full_screen_intent_banner_message))
},
onClick = { onClick = {
state.fullScreenIntentPermissionsState.openFullScreenIntentSettings() state.fullScreenIntentPermissionsState.openFullScreenIntentSettings()
} }
@ -142,15 +152,22 @@ private fun NotificationSettingsContentView(
} }
} }
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_notification_section_title)) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_notification_section_title)) {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_notification_settings_group_chats), headlineContent = {
subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode), Text(stringResource(id = R.string.screen_notification_settings_group_chats))
},
supportingContent = {
Text(getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode))
},
onClick = onGroupChatsClick onClick = onGroupChatsClick
) )
ListItem(
PreferenceText( headlineContent = {
title = stringResource(id = R.string.screen_notification_settings_direct_chats), Text(stringResource(id = R.string.screen_notification_settings_direct_chats))
subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode), },
supportingContent = {
Text(getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode))
},
onClick = onDirectChatsClick onClick = onDirectChatsClick
) )
} }
@ -180,9 +197,10 @@ private fun NotificationSettingsContentView(
) )
} }
PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) { PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) {
PreferenceText( ListItem(
modifier = Modifier, headlineContent = {
title = stringResource(id = R.string.troubleshoot_notifications_entry_point_title), Text(stringResource(id = R.string.troubleshoot_notifications_entry_point_title))
},
onClick = onTroubleshootNotificationsClick onClick = onTroubleshootNotificationsClick
) )
} }

View file

@ -5,13 +5,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoilApi::class)
package io.element.android.features.preferences.impl.tasks package io.element.android.features.preferences.impl.tasks
import android.content.Context import android.content.Context
import coil.Coil import coil3.SingletonImageLoader
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
@ -43,7 +40,7 @@ class DefaultClearCacheUseCase @Inject constructor(
// Clear Matrix cache // Clear Matrix cache
matrixClient.clearCache() matrixClient.clearCache()
// Clear Coil cache // Clear Coil cache
Coil.imageLoader(context).let { SingletonImageLoader.get(context).let {
it.diskCache?.clear() it.diskCache?.clear()
it.memoryCache?.clear() it.memoryCache?.clear()
} }

View file

@ -2,12 +2,38 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Velg hvordan du vil motta varsler"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Velg hvordan du vil motta varsler"</string>
<string name="screen_advanced_settings_developer_mode">"Utviklermodus"</string> <string name="screen_advanced_settings_developer_mode">"Utviklermodus"</string>
<string name="screen_advanced_settings_developer_mode_description">"Aktiver for å få tilgang til funksjoner og funksjonalitet for utviklere."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Deaktiver rik tekstredigering for å skrive Markdown manuelt."</string>
<string name="screen_blocked_users_unblock_alert_action">"Fjern blokkering"</string> <string name="screen_blocked_users_unblock_alert_action">"Fjern blokkering"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du vil kunne se alle meldingene fra dem igjen."</string> <string name="screen_blocked_users_unblock_alert_description">"Du vil kunne se alle meldingene fra dem igjen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Fjern blokkering av bruker"</string> <string name="screen_blocked_users_unblock_alert_title">"Fjern blokkering av bruker"</string>
<string name="screen_edit_profile_display_name">"Visningsnavn"</string> <string name="screen_edit_profile_display_name">"Visningsnavn"</string>
<string name="screen_edit_profile_display_name_placeholder">"Ditt visningsnavn"</string> <string name="screen_edit_profile_display_name_placeholder">"Ditt visningsnavn"</string>
<string name="screen_edit_profile_error">"Det oppstod en ukjent feil, og informasjonen kunne ikke endres."</string>
<string name="screen_edit_profile_error_title">"Kan ikke oppdatere profilen"</string> <string name="screen_edit_profile_error_title">"Kan ikke oppdatere profilen"</string>
<string name="screen_edit_profile_title">"Rediger profil"</string> <string name="screen_edit_profile_title">"Rediger profil"</string>
<string name="screen_edit_profile_updating_details">"Oppdaterer profilen…"</string> <string name="screen_edit_profile_updating_details">"Oppdaterer profilen…"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Ytterligere innstillinger"</string>
<string name="screen_notification_settings_calls_label">"Lyd- og videosamtaler"</string>
<string name="screen_notification_settings_configuration_mismatch">"Uoverensstemmelse i konfigurasjonen"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Vi har forenklet varslingsinnstillingene for å gjøre det lettere å finne alternativene. Noen av de egendefinerte innstillingene du har valgt tidligere, vises ikke her, men de er fortsatt aktive.
Hvis du fortsetter, kan noen av innstillingene dine endres."</string>
<string name="screen_notification_settings_direct_chats">"Direkte chatter"</string>
<string name="screen_notification_settings_edit_custom_settings_section_title">"Egendefinert innstilling per chat"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Det oppstod en feil under oppdatering av varslingsinnstillingen."</string>
<string name="screen_notification_settings_edit_mode_all_messages">"Alle meldinger"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Bare omtaler og nøkkelord"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"På direkte chatter, varsle meg for"</string>
<string name="screen_notification_settings_edit_screen_group_section_header">"I gruppechatter, varsle meg om"</string>
<string name="screen_notification_settings_enable_notifications">"Aktiver varsler på denne enheten"</string>
<string name="screen_notification_settings_failed_fixing_configuration">"Konfigurasjonen er ikke korrigert, prøv igjen."</string>
<string name="screen_notification_settings_group_chats">"Gruppechatter"</string>
<string name="screen_notification_settings_mode_all">"Alle"</string>
<string name="screen_notification_settings_notification_section_title">"Varsle meg om"</string>
<string name="screen_notification_settings_room_mention_label">"Gi meg varsel på @room"</string>
<string name="screen_notification_settings_system_notifications_action_required">"For å motta varsler, vennligst endre %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"systeminnstillinger"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Systemvarsler er slått av"</string>
<string name="screen_notification_settings_title">"Varslinger"</string>
</resources> </resources>

View file

@ -21,6 +21,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.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -40,9 +41,9 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - initial state with blocked users`() = runTest { fun `present - initial state with blocked users`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
ignoredUsersFlow.value = persistentListOf(A_USER_ID) ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
} )
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -56,9 +57,10 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - blocked users list updates with new emissions`() = runTest { fun `present - blocked users list updates with new emissions`() = runTest {
val matrixClient = FakeMatrixClient().apply { val ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
ignoredUsersFlow.value = persistentListOf(A_USER_ID) val matrixClient = FakeMatrixClient(
} ignoredUsersFlow = ignoredUsersFlow
)
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -66,7 +68,7 @@ class BlockedUsersPresenterTest {
with(awaitItem()) { with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID))) assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID)))
} }
matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2)
skipItems(1) skipItems(1)
with(awaitItem()) { with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2)))
@ -77,8 +79,9 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - blocked users list with data`() = runTest { fun `present - blocked users list with data`() = runTest {
val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar") val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar")
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2))
).apply {
givenGetProfileResult(A_USER_ID, Result.success(alice)) givenGetProfileResult(A_USER_ID, Result.success(alice))
givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION)) givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION))
} }
@ -103,9 +106,9 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - unblock user`() = runTest { fun `present - unblock user`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
ignoredUsersFlow.value = persistentListOf(A_USER_ID) ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
} )
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -125,10 +128,10 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - unblock user handles failure`() = runTest { fun `present - unblock user handles failure`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
ignoredUsersFlow.value = persistentListOf(A_USER_ID) unIgnoreUserResult = { Result.failure(IllegalStateException("User not banned")) },
givenUnignoreUserResult(Result.failure(IllegalStateException("User not banned"))) ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
} )
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -147,10 +150,10 @@ class BlockedUsersPresenterTest {
@Test @Test
fun `present - unblock user then cancel`() = runTest { fun `present - unblock user then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
ignoredUsersFlow.value = persistentListOf(A_USER_ID) unIgnoreUserResult = { Result.failure(IllegalStateException("User not banned")) },
givenUnignoreUserResult(Result.failure(IllegalStateException("User not banned"))) ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
} )
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

View file

@ -16,9 +16,10 @@ import io.element.android.features.rageshake.api.R
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSlide import io.element.android.libraries.designsystem.components.preferences.PreferenceSlide
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -52,7 +53,11 @@ fun RageshakePreferencesView(
onValueChange = ::onSensitivityChanged onValueChange = ::onSensitivityChanged
) )
} else { } else {
PreferenceText(title = "Rageshaking is not supported by your device") ListItem(
headlineContent = {
Text("Rageshaking is not supported by your device")
},
)
} }
} }
} }

View file

@ -28,8 +28,8 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.request.ImageRequest import coil3.request.ImageRequest
import io.element.android.features.rageshake.impl.R import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -38,12 +38,13 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceRow import io.element.android.libraries.designsystem.components.preferences.PreferenceRow
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -96,8 +97,10 @@ fun BugReportView(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
PreferenceDivider() PreferenceDivider()
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_bug_report_view_logs), headlineContent = {
Text(stringResource(id = R.string.screen_bug_report_view_logs))
},
enabled = isFormEnabled, enabled = isFormEnabled,
onClick = onViewLogs, onClick = onViewLogs,
) )

View file

@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Legg ved skjermbilde"</string> <string name="screen_bug_report_attach_screenshot">"Legg ved skjermbilde"</string>
<string name="screen_bug_report_contact_me">"Du kan kontakte meg hvis du har noen oppfølgingsspørsmål."</string> <string name="screen_bug_report_contact_me">"Du kan kontakte meg hvis du har noen oppfølgingsspørsmål."</string>
<string name="screen_bug_report_contact_me_title">"Kontakt meg"</string>
<string name="screen_bug_report_edit_screenshot">"Rediger skjermbilde"</string> <string name="screen_bug_report_edit_screenshot">"Rediger skjermbilde"</string>
<string name="screen_bug_report_editor_description">"Vennligst beskriv problemet. Hva har du gjort? Hva forventet du skulle skje? Hva som faktisk skjedde. Vær så detaljert som mulig."</string> <string name="screen_bug_report_editor_description">"Vennligst beskriv problemet. Hva har du gjort? Hva forventet du skulle skje? Hva som faktisk skjedde. Vær så detaljert som mulig."</string>
<string name="screen_bug_report_editor_placeholder">"Beskriv problemet…"</string> <string name="screen_bug_report_editor_placeholder">"Beskriv problemet…"</string>

View file

@ -54,7 +54,6 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@ -481,10 +480,14 @@ private fun TopicSection(
showTopDivider = false, showTopDivider = false,
) { ) {
if (roomTopic is RoomTopicState.CanAddTopic) { if (roomTopic is RoomTopicState.CanAddTopic) {
PreferenceText( ListItem(
title = stringResource(R.string.screen_room_details_add_topic_title), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
icon = CompoundIcons.Plus(), headlineContent = {
onClick = { onActionClick(RoomDetailsAction.AddTopic) }, Text(stringResource(id = R.string.screen_room_details_add_topic_title))
},
onClick = {
onActionClick(RoomDetailsAction.AddTopic)
},
) )
} else if (roomTopic is RoomTopicState.ExistingTopic) { } else if (roomTopic is RoomTopicState.ExistingTopic) {
ClickableLinkText( ClickableLinkText(

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Det oppstod en feil under oppdatering av varslingsinnstillingen."</string>
<string name="screen_room_change_permissions_everyone">"Alle"</string>
<string name="screen_room_change_role_section_administrators">"Administratorer"</string> <string name="screen_room_change_role_section_administrators">"Administratorer"</string>
<string name="screen_room_change_role_section_users">"Medlemmer"</string> <string name="screen_room_change_role_section_users">"Medlemmer"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Du har endringer som ikke er lagret."</string> <string name="screen_room_change_role_unsaved_changes_description">"Du har endringer som ikke er lagret."</string>
@ -15,11 +17,15 @@
<string name="screen_room_details_edition_error_title">"Kan ikke oppdatere rommet"</string> <string name="screen_room_details_edition_error_title">"Kan ikke oppdatere rommet"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Meldingene er krypterte. Det er bare du og mottakerne som har de unike nøklene til å låse dem opp."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Meldingene er krypterte. Det er bare du og mottakerne som har de unike nøklene til å låse dem opp."</string>
<string name="screen_room_details_encryption_enabled_title">"Meldingskryptering aktivert"</string> <string name="screen_room_details_encryption_enabled_title">"Meldingskryptering aktivert"</string>
<string name="screen_room_details_error_loading_notification_settings">"Det oppstod en feil ved lasting av varslingsinnstillinger."</string>
<string name="screen_room_details_error_muting">"Mislyktes i å dempe dette rommet, prøv igjen."</string>
<string name="screen_room_details_error_unmuting">"Mislyktes i å oppheve dempingen av dette rommet, prøv igjen."</string>
<string name="screen_room_details_invite_people_title">"Inviter folk"</string> <string name="screen_room_details_invite_people_title">"Inviter folk"</string>
<string name="screen_room_details_leave_conversation_title">"Forlat samtalen"</string> <string name="screen_room_details_leave_conversation_title">"Forlat samtalen"</string>
<string name="screen_room_details_leave_room_title">"Forlat rommet"</string> <string name="screen_room_details_leave_room_title">"Forlat rommet"</string>
<string name="screen_room_details_notification_mode_custom">"Tilpasset"</string> <string name="screen_room_details_notification_mode_custom">"Tilpasset"</string>
<string name="screen_room_details_notification_mode_default">"Standard"</string> <string name="screen_room_details_notification_mode_default">"Standard"</string>
<string name="screen_room_details_notification_title">"Varslinger"</string>
<string name="screen_room_details_room_name_label">"Romnavn"</string> <string name="screen_room_details_room_name_label">"Romnavn"</string>
<string name="screen_room_details_security_title">"Sikkerhet"</string> <string name="screen_room_details_security_title">"Sikkerhet"</string>
<string name="screen_room_details_share_room_title">"Del rom"</string> <string name="screen_room_details_share_room_title">"Del rom"</string>
@ -59,6 +65,11 @@
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"globale innstillinger"</string> <string name="screen_room_notification_settings_default_setting_footnote_content_link">"globale innstillinger"</string>
<string name="screen_room_notification_settings_default_setting_title">"Standard innstilling"</string> <string name="screen_room_notification_settings_default_setting_title">"Standard innstilling"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Fjern egendefinert innstilling"</string> <string name="screen_room_notification_settings_edit_remove_setting">"Fjern egendefinert innstilling"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Det oppstod en feil ved innlasting av varslingsinnstillinger."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Gjenoppretting av standardmodus mislyktes, prøv igjen."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Innstilling av modus mislyktes, prøv igjen."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle meldinger"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Bare omtaler og nøkkelord"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"I dette rommet, varsle meg om"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"I dette rommet, varsle meg om"</string>
<string name="screen_room_roles_and_permissions_admins">"Administratorer"</string> <string name="screen_room_roles_and_permissions_admins">"Administratorer"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Endre rollen min"</string> <string name="screen_room_roles_and_permissions_change_my_role">"Endre rollen min"</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Du behöver en rumsadress för att göra den synlig i katalogen."</string>
<string name="screen_edit_room_address_title">"Rumsadress"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Ett fel uppstod vid uppdatering av aviseringsinställningen."</string> <string name="screen_notification_settings_edit_failed_updating_default_mode">"Ett fel uppstod vid uppdatering av aviseringsinställningen."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."</string> <string name="screen_notification_settings_mentions_only_disclaimer">"Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."</string>
<string name="screen_polls_history_title">"Omröstningar"</string> <string name="screen_polls_history_title">"Omröstningar"</string>
@ -132,10 +134,18 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"Användare kan bara gå med om de är inbjudna"</string> <string name="screen_security_and_privacy_room_access_invite_only_option_description">"Användare kan bara gå med om de är inbjudna"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_title">"Endast inbjudan"</string> <string name="screen_security_and_privacy_room_access_invite_only_option_title">"Endast inbjudan"</string>
<string name="screen_security_and_privacy_room_access_section_header">"Tillgång till rum"</string> <string name="screen_security_and_privacy_room_access_section_header">"Tillgång till rum"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_description">"Utrymmen stöds för närvarande inte"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_title">"Utrymmesmedlemmar"</string>
<string name="screen_security_and_privacy_room_address_section_header">"Rumsadress"</string>
<string name="screen_security_and_privacy_room_directory_visibility_section_footer">"Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Vem som helst"</string> <string name="screen_security_and_privacy_room_history_anyone_option_title">"Vem som helst"</string>
<string name="screen_security_and_privacy_room_history_section_header">"Vem kan läsa historik"</string> <string name="screen_security_and_privacy_room_history_section_header">"Vem kan läsa historik"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Endast medlemmar sedan de bjöds in"</string> <string name="screen_security_and_privacy_room_history_since_invite_option_title">"Endast medlemmar sedan de bjöds in"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Endast medlemmar sedan det här alternativet har valts"</string> <string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Endast medlemmar sedan det här alternativet har valts"</string>
<string name="screen_security_and_privacy_room_publishing_section_footer">"Rumsadresser är sätt att hitta och komma åt rum. Detta säkerställer också att du enkelt kan dela ditt rum med andra.
Du kan välja att publicera ditt rum i din hemservers offentliga rumskatalog."</string>
<string name="screen_security_and_privacy_room_visibility_section_footer">"Rumsadresser är sätt att hitta och komma åt rum. Detta säkerställer också att du enkelt kan dela ditt rum med andra.
Adressen krävs också för att rummet ska synas i den allmänna rumskatalogen på %1$s."</string>
<string name="screen_security_and_privacy_room_visibility_section_header">"Rumssynlighet"</string> <string name="screen_security_and_privacy_room_visibility_section_header">"Rumssynlighet"</string>
<string name="screen_security_and_privacy_title">"Säkerhet och sekretess"</string> <string name="screen_security_and_privacy_title">"Säkerhet och sekretess"</string>
</resources> </resources>

View file

@ -28,7 +28,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onSessionConfirmRecoveryKeyClick() fun onSessionConfirmRecoveryKeyClick()
fun onRoomSettingsClick(roomId: RoomId) fun onRoomSettingsClick(roomId: RoomId)
fun onReportBugClick() fun onReportBugClick()
fun onRoomDirectorySearchClick()
fun onLogoutForNativeSlidingSyncMigrationNeeded() fun onLogoutForNativeSlidingSyncMigrationNeeded()
} }
} }

View file

@ -82,10 +82,6 @@ class RoomListNode @AssistedInject constructor(
} }
} }
private fun onRoomDirectorySearchClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomDirectorySearchClick() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -100,7 +96,6 @@ class RoomListNode @AssistedInject constructor(
onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
onRoomSettingsClick = this::onRoomSettingsClick, onRoomSettingsClick = this::onRoomSettingsClick,
onMenuActionClick = { onMenuActionClick(activity, it) }, onMenuActionClick = { onMenuActionClick(activity, it) },
onRoomDirectorySearchClick = this::onRoomDirectorySearchClick,
modifier = modifier, modifier = modifier,
) { ) {
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(

View file

@ -51,6 +51,7 @@ 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.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -89,6 +90,7 @@ class RoomListPresenter @Inject constructor(
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>, private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val logoutPresenter: Presenter<DirectLogoutState>, private val logoutPresenter: Presenter<DirectLogoutState>,
private val appPreferencesStore: AppPreferencesStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService() private val encryptionService: EncryptionService = client.encryptionService()
@ -245,7 +247,8 @@ class RoomListPresenter @Inject constructor(
isFavorite = event.roomListRoomSummary.isFavorite, isFavorite = event.roomListRoomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread), markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent, hasNewContent = event.roomListRoomSummary.hasNewContent,
eventCacheFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.EventCache), eventCacheFeatureFlagEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() &&
featureFlagService.isFeatureEnabled(FeatureFlags.EventCache),
) )
contextMenuState.value = initialState contextMenuState.value = initialState
@ -329,9 +332,12 @@ class RoomListPresenter @Inject constructor(
} }
@VisibleForTesting @VisibleForTesting
internal fun RoomListRoomSummary.toInviteData() = InviteData( internal fun RoomListRoomSummary.toInviteData(): InviteData? {
roomId = roomId, if (inviteSender == null) return null
// Note: `name` should not be null at this point, but just in case, fallback to the roomId return InviteData(
roomName = name ?: roomId.value, roomId = roomId,
isDm = isDm, roomName = name ?: roomId.value,
) isDm = isDm,
senderId = inviteSender.userId,
)
}

View file

@ -49,7 +49,6 @@ fun RoomListView(
onCreateRoomClick: () -> Unit, onCreateRoomClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit, onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit,
onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit, acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
@ -83,7 +82,6 @@ fun RoomListView(
state = state.searchState, state = state.searchState,
eventSink = state.eventSink, eventSink = state.eventSink,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
modifier = Modifier modifier = Modifier
.statusBarsPadding() .statusBarsPadding()
.padding(top = topPadding) .padding(top = topPadding)
@ -177,7 +175,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onCreateRoomClick = {}, onCreateRoomClick = {},
onRoomSettingsClick = {}, onRoomSettingsClick = {},
onMenuActionClick = {}, onMenuActionClick = {},
onRoomDirectorySearchClick = {},
acceptDeclineInviteView = {}, acceptDeclineInviteView = {},
) )
} }

View file

@ -15,14 +15,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
class RoomListSearchPresenter @Inject constructor( class RoomListSearchPresenter @Inject constructor(
private val dataSource: RoomListSearchDataSource, private val dataSource: RoomListSearchDataSource,
private val featureFlagService: FeatureFlagService,
) : Presenter<RoomListSearchState> { ) : Presenter<RoomListSearchState> {
@Composable @Composable
override fun present(): RoomListSearchState { override fun present(): RoomListSearchState {
@ -57,14 +54,12 @@ class RoomListSearchPresenter @Inject constructor(
} }
} }
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf()) val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
return RoomListSearchState( return RoomListSearchState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
query = searchQuery, query = searchQuery,
results = searchResults, results = searchResults,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -14,8 +14,5 @@ data class RoomListSearchState(
val isSearchActive: Boolean, val isSearchActive: Boolean,
val query: String, val query: String,
val results: ImmutableList<RoomListRoomSummary>, val results: ImmutableList<RoomListRoomSummary>,
val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (RoomListSearchEvents) -> Unit val eventSink: (RoomListSearchEvents) -> Unit
) { )
val displayRoomDirectorySearch = query.isEmpty() && isRoomDirectorySearchEnabled
}

View file

@ -17,7 +17,6 @@ class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState
override val values: Sequence<RoomListSearchState> override val values: Sequence<RoomListSearchState>
get() = sequenceOf( get() = sequenceOf(
aRoomListSearchState(), aRoomListSearchState(),
aRoomListSearchState(isRoomDirectorySearchEnabled = true),
aRoomListSearchState( aRoomListSearchState(
isSearchActive = true, isSearchActive = true,
query = "Test", query = "Test",
@ -30,12 +29,10 @@ fun aRoomListSearchState(
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
query: String = "", query: String = "",
results: ImmutableList<RoomListRoomSummary> = persistentListOf(), results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
isRoomDirectorySearchEnabled: Boolean = false,
eventSink: (RoomListSearchEvents) -> Unit = { }, eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState( ) = RoomListSearchState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
query = query, query = query,
results = results, results = results,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -12,12 +12,9 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -26,7 +23,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -38,22 +34,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListEvents import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.FilledTextField import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.copy import io.element.android.libraries.designsystem.utils.copy
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -64,7 +56,6 @@ internal fun RoomListSearchView(
state: RoomListSearchState, state: RoomListSearchState,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit, onRoomClick: (RoomId) -> Unit,
onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(enabled = state.isSearchActive) { BackHandler(enabled = state.isSearchActive) {
@ -91,7 +82,6 @@ internal fun RoomListSearchView(
state = state, state = state,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,
eventSink = eventSink, eventSink = eventSink,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
) )
} }
} }
@ -104,7 +94,6 @@ private fun RoomListSearchContent(
state: RoomListSearchState, state: RoomListSearchState,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit, onRoomClick: (RoomId) -> Unit,
onRoomDirectorySearchClick: () -> Unit,
) { ) {
val borderColor = MaterialTheme.colorScheme.tertiary val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp val strokeWidth = 1.dp
@ -175,14 +164,6 @@ private fun RoomListSearchContent(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
if (state.displayRoomDirectorySearch) {
RoomDirectorySearchButton(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
onClick = onRoomDirectorySearchClick
)
}
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
@ -201,31 +182,6 @@ private fun RoomListSearchContent(
} }
} }
@Composable
private fun RoomDirectorySearchButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
SuperButton(
onClick = onClick,
modifier = modifier,
buttonSize = ButtonSize.Large,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = CompoundIcons.ListBulleted(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.screen_roomlist_room_directory_button_title),
)
}
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
@ -233,6 +189,5 @@ internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateP
state = state, state = state,
onRoomClick = {}, onRoomClick = {},
eventSink = {}, eventSink = {},
onRoomDirectorySearchClick = {},
) )
} }

View file

@ -8,6 +8,8 @@
<string name="screen_invites_decline_direct_chat_title">"Avslå chat"</string> <string name="screen_invites_decline_direct_chat_title">"Avslå chat"</string>
<string name="screen_invites_empty_list">"Ingen invitasjoner"</string> <string name="screen_invites_empty_list">"Ingen invitasjoner"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) inviterte deg"</string> <string name="screen_invites_invited_you">"%1$s(%2$s) inviterte deg"</string>
<string name="screen_migration_message">"Dette er en engangsprosess, takk for at du venter."</string>
<string name="screen_migration_title">"Setter opp kontoen din."</string>
<string name="screen_roomlist_a11y_create_message">"Opprett en ny samtale eller et nytt rom"</string> <string name="screen_roomlist_a11y_create_message">"Opprett en ny samtale eller et nytt rom"</string>
<string name="screen_roomlist_empty_message">"Kom i gang med å sende meldinger til noen."</string> <string name="screen_roomlist_empty_message">"Kom i gang med å sende meldinger til noen."</string>
<string name="screen_roomlist_empty_title">"Ingen chatter ennå."</string> <string name="screen_roomlist_empty_title">"Ingen chatter ennå."</string>

View file

@ -67,7 +67,9 @@ import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
@ -312,6 +314,35 @@ class RoomListPresenterTest {
} }
} }
@Test
fun `present - show context menu with view source on`() = runTest {
val presenter = createRoomListPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
isDeveloperModeEnabled = true,
)
)
presenter.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
// true here.
eventCacheFeatureFlagEnabled = true,
hasNewContent = false,
)
)
}
}
}
@Test @Test
fun `present - hide context menu`() = runTest { fun `present - hide context menu`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -643,6 +674,7 @@ class RoomListPresenterTest {
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() }, searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@ -672,6 +704,7 @@ class RoomListPresenterTest {
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() }, logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,
) )
} }

View file

@ -240,7 +240,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onCreateRoomClick: () -> Unit = EnsureNeverCalled(), onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
RoomListView( RoomListView(
@ -252,7 +251,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onCreateRoomClick = onCreateRoomClick, onCreateRoomClick = onCreateRoomClick,
onRoomSettingsClick = onRoomSettingsClick, onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick, onMenuActionClick = onMenuActionClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
acceptDeclineInviteView = { }, acceptDeclineInviteView = { },
) )
} }

View file

@ -14,9 +14,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
@ -118,26 +115,10 @@ class RoomListSearchPresenterTest {
} }
} }
} }
@Test
fun `present - room directory search`() = runTest {
val featureFlagService = FakeFeatureFlagService()
featureFlagService.setFeatureEnabled(FeatureFlags.RoomDirectorySearch, true)
val presenter = createRoomListSearchPresenter(featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().let { state ->
assertThat(state.isRoomDirectorySearchEnabled).isTrue()
}
}
}
} }
fun TestScope.createRoomListSearchPresenter( fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListSearchPresenter { ): RoomListSearchPresenter {
return RoomListSearchPresenter( return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource( dataSource = RoomListSearchDataSource(
@ -148,6 +129,5 @@ fun TestScope.createRoomListSearchPresenter(
), ),
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
), ),
featureFlagService = featureFlagService,
) )
} }

View file

@ -1,62 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomlist.impl.search
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListSearchViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on 'Browse all rooms' invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListSearchEvents>(expectEvents = false)
ensureCalledOnce {
rule.setRoomListSearchView(
aRoomListSearchState(
isSearchActive = true,
isRoomDirectorySearchEnabled = true,
eventSink = eventsRecorder,
),
onRoomDirectorySearchClick = it,
)
rule.clickOn(R.string.screen_roomlist_room_directory_button_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListSearchView(
state: RoomListSearchState,
eventSink: (RoomListEvents) -> Unit = EventsRecorder(expectEvents = false),
onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomListSearchView(
state = state,
eventSink = eventSink,
onRoomClick = onRoomClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
)
}
}

View file

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_enable">"Slå på sikkerhetskopiering"</string>
<string name="screen_chat_backup_key_backup_description">"Lagre din kryptografiske identitet og meldingsnøkler sikkert på serveren. Dette gjør at du kan se meldingshistorikken din på alle nye enheter. %1$s."</string>
<string name="screen_chat_backup_recovery_action_change">"Endre gjenopprettingsnøkkel"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Skriv inn gjenopprettingsnøkkel"</string> <string name="screen_chat_backup_recovery_action_confirm">"Skriv inn gjenopprettingsnøkkel"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Få tilgang til de krypterte meldingene dine hvis du mister alle enhetene dine eller blir logget ut av %1$s overalt."</string>
<string name="screen_encryption_reset_action_continue_reset">"Fortsett tilbakestillingen"</string> <string name="screen_encryption_reset_action_continue_reset">"Fortsett tilbakestillingen"</string>
<string name="screen_encryption_reset_bullet_1">"Dine kontodetaljer, kontakter, innstillinger og chatteliste vil bli beholdt"</string> <string name="screen_encryption_reset_bullet_1">"Dine kontodetaljer, kontakter, innstillinger og chatteliste vil bli beholdt"</string>
<string name="screen_encryption_reset_bullet_2">"Du mister all meldingshistorikk som bare er lagret på serveren"</string> <string name="screen_encryption_reset_bullet_2">"Du mister all meldingshistorikk som bare er lagret på serveren"</string>
@ -8,5 +12,6 @@
<string name="screen_encryption_reset_footer">"Tilbakestill identiteten din bare hvis du ikke har tilgang til en annen pålogget enhet og du har mistet gjenopprettingsnøkkelen."</string> <string name="screen_encryption_reset_footer">"Tilbakestill identiteten din bare hvis du ikke har tilgang til en annen pålogget enhet og du har mistet gjenopprettingsnøkkelen."</string>
<string name="screen_encryption_reset_title">"Kan du ikke bekrefte? Du må tilbakestille identiteten din."</string> <string name="screen_encryption_reset_title">"Kan du ikke bekrefte? Du må tilbakestille identiteten din."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slå av"</string> <string name="screen_key_backup_disable_confirmation_action_turn_off">"Slå av"</string>
<string name="screen_key_backup_disable_description_point_1">"Du vil ikke ha kryptert meldingshistorikk på nye enheter"</string>
<string name="screen_recovery_key_confirm_title">"Skriv inn gjenopprettingsnøkkelen din"</string> <string name="screen_recovery_key_confirm_title">"Skriv inn gjenopprettingsnøkkelen din"</string>
</resources> </resources>

View file

@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signed_out_reason_1">"Du har endret passordet ditt i en annen sesjon"</string>
<string name="screen_signed_out_reason_2">"Du har slettet sesjonen fra en annen sesjon"</string>
<string name="screen_signed_out_reason_3">"Serveradministratoren har ugyldiggjort tilgangen din"</string>
<string name="screen_signed_out_subtitle">"Du kan ha blitt logget ut av en av årsakene som er oppført nedenfor. Logg på igjen for å fortsette å bruke %s."</string>
<string name="screen_signed_out_title">"Du er logget ut"</string> <string name="screen_signed_out_title">"Du er logget ut"</string>
</resources> </resources>

View file

@ -40,7 +40,11 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -169,7 +173,8 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val client = createFakeMatrixClient() val ignoredUsersFlow = MutableStateFlow(persistentListOf<UserId>())
val client = createFakeMatrixClient(ignoredUsersFlow = ignoredUsersFlow)
val presenter = createUserProfilePresenter( val presenter = createUserProfilePresenter(
client = client, client = client,
userId = A_USER_ID userId = A_USER_ID
@ -178,20 +183,21 @@ class UserProfilePresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf(A_USER_ID)) ignoredUsersFlow.emit(persistentListOf(A_USER_ID))
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue() assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf()) ignoredUsersFlow.emit(persistentListOf())
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse() assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
} }
} }
@Test @Test
fun `present - BlockUser with error`() = runTest { fun `present - BlockUser with error`() = runTest {
val matrixClient = createFakeMatrixClient() val matrixClient = createFakeMatrixClient(
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE)) ignoreUserResult = { Result.failure(A_THROWABLE) }
)
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
@ -207,8 +213,9 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - UnblockUser with error`() = runTest { fun `present - UnblockUser with error`() = runTest {
val matrixClient = createFakeMatrixClient() val matrixClient = createFakeMatrixClient(
matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE)) unIgnoreUserResult = { Result.failure(A_THROWABLE) }
)
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
@ -374,10 +381,16 @@ class UserProfilePresenterTest {
private fun createFakeMatrixClient( private fun createFakeMatrixClient(
isUserVerified: Boolean = false, isUserVerified: Boolean = false,
ignoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
) = FakeMatrixClient( ) = FakeMatrixClient(
encryptionService = FakeEncryptionService( encryptionService = FakeEncryptionService(
isUserVerifiedResult = { Result.success(isUserVerified) } isUserVerifiedResult = { Result.success(isUserVerified) }
), ),
ignoreUserResult = ignoreUserResult,
unIgnoreUserResult = unIgnoreUserResult,
ignoredUsersFlow = ignoredUsersFlow
) )
private fun createUserProfilePresenter( private fun createUserProfilePresenter(

View file

@ -6,7 +6,7 @@
android_gradle_plugin = "8.8.1" android_gradle_plugin = "8.8.1"
kotlin = "2.1.10" kotlin = "2.1.10"
kotlinpoet = "2.1.0" kotlinpoet = "2.1.0"
ksp = "2.1.10-1.0.30" ksp = "2.1.10-1.0.31"
firebaseAppDistribution = "5.1.1" firebaseAppDistribution = "5.1.1"
# AndroidX # AndroidX
@ -42,7 +42,7 @@ datetime = "0.6.2"
serialization_json = "1.8.0" serialization_json = "1.8.0"
#other #other
coil = "2.7.0" coil = "3.1.0"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.6.0" appyx = "1.6.0"
sqldelight = "2.0.2" sqldelight = "2.0.2"
@ -77,7 +77,7 @@ kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", ve
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
gms_google_services = "com.google.gms:google-services:4.4.2" gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:33.9.0" google_firebase_bom = "com.google.firebase:firebase-bom:33.10.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } 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" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@ -149,7 +149,7 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2" test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2" test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.16" test_mockk = "io.mockk:mockk:1.13.17"
test_konsist = "com.lemonappdev:konsist:0.17.3" test_konsist = "com.lemonappdev:konsist:0.17.3"
test_turbine = "app.cash.turbine:turbine:1.2.0" test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4" test_truth = "com.google.truth:truth:1.4.4"
@ -159,21 +159,22 @@ test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "
test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.5.1" test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.5.1"
# Others # Others
coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "25.2.26" } compound = { module = "io.element.android:compound-android", version = "25.2.26" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8" kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.18.3" jsoup = "org.jsoup:jsoup:1.19.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.2.26" matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.6"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", 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" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -187,7 +188,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.8.1" maplibre = "org.maplibre.gl:android-sdk:11.8.2"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0" opusencoder = "io.element.android:opusencoder:1.1.0"
@ -233,7 +234,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.8" detekt = "io.gitlab.arturbosch.detekt:1.23.8"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.2" ktlint = "org.jlleitschuh.gradle.ktlint:12.2.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.1.0" dependencycheck = "org.owasp.dependencycheck:12.1.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -28,3 +28,21 @@ fun Boolean.toSecondaryEnabledColor(): Color {
ElementTheme.colors.textDisabled ElementTheme.colors.textDisabled
} }
} }
@Composable
fun Boolean.toIconEnabledColor(): Color {
return if (this) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.iconDisabled
}
}
@Composable
fun Boolean.toIconSecondaryEnabledColor(): Color {
return if (this) {
ElementTheme.colors.iconSecondary
} else {
ElementTheme.colors.iconDisabled
}
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.os.Build import android.os.Build
import android.text.TextPaint import android.text.TextPaint
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
@ -85,9 +84,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.toSize
import coil.imageLoader import coil3.SingletonImageLoader
import coil.request.DefaultRequestOptions import coil3.request.ImageRequest
import coil.request.ImageRequest import coil3.request.allowHardware
import coil3.toBitmap
import com.airbnb.android.showkase.annotation.ShowkaseComposable import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -328,7 +328,7 @@ fun Modifier.avatarBloom(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(avatarData) .data(avatarData)
// Allow cache and default dispatchers // Allow cache and default dispatchers
.defaults(DefaultRequestOptions()) .defaults(ImageRequest.Defaults())
// Needed to be able to read pixels from the Bitmap for the hash // Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false) .allowHardware(false)
// Reduce size so it loads faster for large avatars // Reduce size so it loads faster for large avatars
@ -340,9 +340,11 @@ fun Modifier.avatarBloom(
var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) } var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) { LaunchedEffect(avatarData) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val drawable = val bitmap = SingletonImageLoader.get(context)
context.imageLoader.execute(painterRequest).drawable ?: return@withContext .execute(painterRequest)
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext .image
?.toBitmap()
?: return@withContext
blurHash = BlurHash.encode( blurHash = BlurHash.encode(
bitmap = bitmap, bitmap = bitmap,
componentX = BloomDefaults.HASH_COMPONENTS, componentX = BloomDefaults.HASH_COMPONENTS,

View file

@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -26,10 +28,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
@ -89,7 +91,8 @@ private fun ImageAvatar(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
) { ) {
when (val state = painter.state) { val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> { is AsyncImagePainter.State.Error -> {
SideEffect { SideEffect {

View file

@ -20,7 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage import coil3.compose.AsyncImage
@Composable @Composable
fun BlurHashAsyncImage( fun BlurHashAsyncImage(

View file

@ -47,10 +47,6 @@ internal fun PreferenceCategoryPreview() = ElementThemedPreview {
PreferenceCategory( PreferenceCategory(
title = "Category title", title = "Category title",
) { ) {
PreferenceText(
title = "Title",
icon = CompoundIcons.ChatProblem(),
)
PreferenceSwitch( PreferenceSwitch(
title = "Switch", title = "Switch",
icon = CompoundIcons.Threads(), icon = CompoundIcons.Threads(),

View file

@ -97,11 +97,6 @@ internal fun PreferencePagePreview() = ElementPreview {
PreferenceCategory( PreferenceCategory(
title = "Category title", title = "Category title",
) { ) {
PreferenceText(
title = "Title",
subtitle = "Some other text",
icon = CompoundIcons.ChatProblem(),
)
PreferenceDivider() PreferenceDivider()
PreferenceSwitch( PreferenceSwitch(
title = "Switch", title = "Switch",

View file

@ -1,216 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.preferences
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
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.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
@Composable
fun PreferenceText(
title: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
subtitle: String? = null,
subtitleAnnotated: AnnotatedString? = null,
currentValue: String? = null,
loadingCurrentValue: Boolean = false,
icon: ImageVector? = null,
@DrawableRes iconResourceId: Int? = null,
showIconAreaIfNoIcon: Boolean = false,
showIconBadge: Boolean = false,
showEndBadge: Boolean = false,
tintColor: Color? = null,
onClick: () -> Unit = {},
) {
ListItem(
modifier = modifier,
enabled = enabled,
onClick = onClick,
leadingContent = preferenceIcon(
icon = icon,
iconResourceId = iconResourceId,
showIconBadge = showIconBadge,
enabled = enabled,
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
tintColor = tintColor,
),
headlineContent = {
Text(
style = ElementTheme.typography.fontBodyLgRegular,
text = title,
color = tintColor ?: enabled.toEnabledColor(),
)
},
supportingContent = if (subtitle != null) {
{
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = subtitle,
color = tintColor ?: enabled.toSecondaryEnabledColor(),
)
}
} else {
subtitleAnnotated?.let {
{
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = it,
color = tintColor ?: enabled.toSecondaryEnabledColor(),
)
}
}
},
trailingContent = if (currentValue != null || loadingCurrentValue || showEndBadge) {
ListItemContent.Custom {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (currentValue != null) {
Text(
text = currentValue,
style = ElementTheme.typography.fontBodyXsMedium,
color = enabled.toSecondaryEnabledColor(),
)
} else if (loadingCurrentValue) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
strokeWidth = 2.dp
)
}
if (showEndBadge) {
val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 16.dp else 0.dp
RedIndicatorAtom(
modifier = Modifier
.padding(start = endBadgeStartPadding)
)
}
}
}
} else {
null
}
)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextLightPreview() = ElementPreviewLight {
ContentToPreview(showEndBadge = false)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = false)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextWithEndBadgeLightPreview() = ElementPreviewLight {
ContentToPreview(showEndBadge = true)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = true)
}
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(showEndBadge: Boolean) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
PreferenceText(
title = "Title",
icon = CompoundIcons.ChatProblem(),
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = CompoundIcons.ChatProblem(),
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = CompoundIcons.ChatProblem(),
currentValue = "123",
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = CompoundIcons.ChatProblem(),
currentValue = "123",
enabled = false,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = CompoundIcons.ChatProblem(),
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
icon = CompoundIcons.ChatProblem(),
currentValue = "123",
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
icon = CompoundIcons.ChatProblem(),
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title no icon with icon area",
showIconAreaIfNoIcon = true,
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title no icon",
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
}
}

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.toSecondaryEnabledColor import io.element.android.libraries.designsystem.toIconSecondaryEnabledColor
@Composable @Composable
fun preferenceIcon( fun preferenceIcon(
@ -68,7 +68,7 @@ private fun PreferenceIcon(
imageVector = icon, imageVector = icon,
resourceId = iconResourceId, resourceId = iconResourceId,
contentDescription = null, contentDescription = null,
tint = tintColor ?: enabled.toSecondaryEnabledColor(), tint = tintColor ?: enabled.toIconSecondaryEnabledColor(),
modifier = Modifier modifier = Modifier
.size(24.dp), .size(24.dp),
) )

View file

@ -9,9 +9,14 @@ package io.element.android.libraries.designsystem.preview
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
@OptIn(ExperimentalCoilApi::class)
@Composable @Composable
@Suppress("ModifierMissing") @Suppress("ModifierMissing")
fun ElementPreview( fun ElementPreview(
@ -19,12 +24,14 @@ fun ElementPreview(
showBackground: Boolean = true, showBackground: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
ElementTheme(darkTheme = darkTheme) { CompositionLocalProvider(LocalAsyncImagePreviewHandler provides AsyncImagePreviewHandler { null }) {
if (showBackground) { ElementTheme(darkTheme = darkTheme) {
// If we have a proper contentColor applied we need a Surface instead of a Box if (showBackground) {
Surface(content = content) // If we have a proper contentColor applied we need a Surface instead of a Box
} else { Surface(content = content)
content() } else {
content()
}
} }
} }
} }

View file

@ -193,14 +193,14 @@ sealed interface ListItemStyle {
@Composable @Composable
fun leadingIconColor() = when (this) { fun leadingIconColor() = when (this) {
Default -> ListItemDefaultColors.icon Default -> ListItemDefaultColors.leadingIcon
Primary -> ElementTheme.colors.iconPrimary Primary -> ElementTheme.colors.iconPrimary
Destructive -> ElementTheme.colors.iconCriticalPrimary Destructive -> ElementTheme.colors.iconCriticalPrimary
} }
@Composable @Composable
fun trailingIconColor() = when (this) { fun trailingIconColor() = when (this) {
Default -> ListItemDefaultColors.icon Default -> ListItemDefaultColors.trailingIcon
Primary -> ElementTheme.colors.iconPrimary Primary -> ElementTheme.colors.iconPrimary
Destructive -> ElementTheme.colors.iconCriticalPrimary Destructive -> ElementTheme.colors.iconCriticalPrimary
} }
@ -210,15 +210,16 @@ object ListItemDefaultColors {
val headline: Color @Composable get() = ElementTheme.colors.textPrimary val headline: Color @Composable get() = ElementTheme.colors.textPrimary
val headlineDisabled: Color @Composable get() = ElementTheme.colors.textDisabled val headlineDisabled: Color @Composable get() = ElementTheme.colors.textDisabled
val supportingText: Color @Composable get() = ElementTheme.materialColors.onSurfaceVariant val supportingText: Color @Composable get() = ElementTheme.materialColors.onSurfaceVariant
val icon: Color @Composable get() = ElementTheme.colors.iconTertiary val leadingIcon: Color @Composable get() = ElementTheme.colors.iconSecondary
val trailingIcon: Color @Composable get() = ElementTheme.colors.iconPrimary
val iconDisabled: Color @Composable get() = ElementTheme.colors.iconDisabled val iconDisabled: Color @Composable get() = ElementTheme.colors.iconDisabled
val colors: ListItemColors val colors: ListItemColors
@Composable get() = ListItemDefaults.colors( @Composable get() = ListItemDefaults.colors(
headlineColor = headline, headlineColor = headline,
supportingColor = supportingText, supportingColor = supportingText,
leadingIconColor = icon, leadingIconColor = leadingIcon,
trailingIconColor = icon, trailingIconColor = trailingIcon,
disabledHeadlineColor = headlineDisabled, disabledHeadlineColor = headlineDisabled,
disabledLeadingIconColor = iconDisabled, disabledLeadingIconColor = iconDisabled,
disabledTrailingIconColor = iconDisabled, disabledTrailingIconColor = iconDisabled,

View file

@ -165,7 +165,7 @@ enum class FeatureFlags(
key = "feature.event_cache", key = "feature.event_cache",
title = "Use SDK Event cache", title = "Use SDK Event cache",
description = "Warning: you must kill and restart the app for the change to take effect.", description = "Warning: you must kill and restart the app for the change to take effect.",
defaultValue = { false }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
} }

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