Merge remote-tracking branch 'origin/develop' into mxandreas/disable-public-rooms

# Conflicts:
#	enterprise
This commit is contained in:
mxandreas
2026-10-09 09:58:27 +03:00
580 changed files with 7016 additions and 2776 deletions
-2
View File
@@ -16,6 +16,4 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.uiStrings)
}
@@ -17,4 +17,6 @@ sealed interface Announcement {
}
data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
}
@@ -29,10 +29,11 @@ class DefaultAnnouncementService(
) : AnnouncementService {
override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) {
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement)
is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement)
Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
}
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
}
}
@@ -44,16 +45,22 @@ class DefaultAnnouncementService(
return combine(
flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { _, newNotificationSoundStatus ->
announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus ->
buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound)
}
// The multi-account announcement is pending until it is dismissed. Users who were already
// logged in when the feature became available still have the default NeverShown status.
if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount)
}
}
}
}
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) {
private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) {
val currentValue = announcementStore.announcementStatusFlow(announcement).first()
if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
@ContributesBinding(AppScope::class)
class DefaultAnnouncementStore(
@@ -36,11 +37,15 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey()
// Default-status convention: Shown for one-shot intro/feature announcements that should
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
// in by AppMigration08).
// NOT replay on first launch.
// - Fullscreen.Space is disabled
// - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
}
return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -56,4 +61,5 @@ class DefaultAnnouncementStore(
private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey
}
@@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
}
@Test
fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.MultiAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
@@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore,
)
sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty()
// MultiAccount is pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty()
}
}
@@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
@@ -31,10 +33,12 @@ class InMemoryAnnouncementStore(
override suspend fun reset() {
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
}
private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement
}
}
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.test.logs
package io.element.android.features.announcement.test
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-1
View File
@@ -17,6 +17,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}
-1
View File
@@ -90,7 +90,6 @@ dependencies {
api(projects.features.call.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
@@ -29,7 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import kotlin.time.Duration.Companion.seconds
@@ -31,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -6,17 +6,16 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
package io.element.android.features.call.impl
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeNativeCallEntryPoint
import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint
import io.element.android.features.callnative.api.NativeCallEntryPoint
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
package io.element.android.features.call.impl
import android.Manifest
import android.webkit.PermissionRequest
@@ -6,13 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.notifications
package io.element.android.features.call.impl.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
@@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test
class CallScreenBackPressPolicyTest {
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -14,13 +14,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.utils.FakeCallWidgetProvider
import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.view.KeyEvent
import android.webkit.WebView
@@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenState
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,9 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
package io.element.android.features.call.impl.ui
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import io.element.android.features.call.impl.ui.LanguageTagProvider
class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider {
@Composable
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
@@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -6,12 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData
import io.element.android.features.callnative.api.NativeCallEntryPoint
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
-1
View File
@@ -17,7 +17,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api)
implementation(projects.libraries.matrix.api)
@@ -47,8 +47,6 @@ dependencies {
testCommonDependencies(libs)
testImplementation(libs.element.call.test)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.services.appnavstate.test)
}
@@ -8,16 +8,8 @@ android {
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -24,7 +24,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@@ -34,7 +34,6 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
@@ -52,7 +51,6 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.enterprise.test)
}
@@ -8,7 +8,7 @@
<string name="screen_create_room_new_room_title">"新房间"</string>
<string name="screen_create_room_new_space_title">"新空间"</string>
<string name="screen_create_room_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_private_option_title">"私密"</string>
<string name="screen_create_room_private_option_title">"私有"</string>
<string name="screen_create_room_public_option_description">"任何人都能找到此房间。
你可以随时在房间设置中更改。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入,包括你的组织之外的人员。"</string>
@@ -18,7 +18,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"%1$s 中的任何人都可以加入,但其他人必须申请访问。"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"申请加入"</string>
<string name="screen_create_room_room_access_section_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私密"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私有"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入,包括你的组织之外的人员。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公共"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s 中的任何人都可以加入。"</string>
@@ -6,10 +6,11 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.api
package io.element.android.features.createroom.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
@@ -28,7 +28,6 @@ setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
sealed interface AccountDeactivationEvent {
data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import android.os.Parcelable
import io.element.android.libraries.architecture.AsyncAction
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -44,8 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.deactivation.impl.R
import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog
import io.element.android.features.deactivation.impl.ui.AccountDeactivationActionDialog
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl.ui
package io.element.android.features.deactivation.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl.ui
package io.element.android.features.deactivation.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -8,7 +8,7 @@
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_PASSWORD
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
package io.element.android.features.deactivation.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
-1
View File
@@ -17,5 +17,4 @@ dependencies {
implementation(projects.libraries.compound)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
}
@@ -25,7 +25,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(libs.serialization.json)
testCommonDependencies(libs)
@@ -18,6 +18,5 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils)
}
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
}
data class Params(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
buildContext = buildContext,
plugins = listOf(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
),
callback,
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -40,6 +41,7 @@ class ForwardMessagesNode(
@Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -52,17 +54,18 @@ class ForwardMessagesNode(
object NavTarget : Parcelable
data class Inputs(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val callback: ForwardEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
// In Forward mode, the session cannot be changed
presenter.onRoomSelected(roomIds)
}
@@ -75,6 +78,7 @@ class ForwardMessagesNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -28,22 +28,20 @@ import timber.log.Timber
@AssistedInject
class ForwardMessagesPresenter(
@Assisted eventId: String,
@Assisted private val eventIds: List<EventId>,
@Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
fun interface Factory {
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
fun create(eventIds: List<EventId>, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds)
sessionCoroutineScope.forwardEvents(eventIds, roomIds)
}
@Composable
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
)
}
private fun CoroutineScope.forwardEvent(
eventId: EventId,
private fun CoroutineScope.forwardEvents(
eventIds: List<EventId>,
roomIds: List<RoomId>,
) = launch {
suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds)
timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
.onFailure {
Timber.e(it, "Error while forwarding event")
Timber.e(it, "Error while forwarding events")
}
.getOrThrow()
roomIds
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -39,13 +40,14 @@ class DefaultForwardEntryPointTest {
plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
}
val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID,
eventIds = listOf(AN_EVENT_ID),
timelineProvider = FakeTimelineProvider(),
)
val result = entryPoint.createNode(
@@ -57,7 +59,7 @@ class DefaultForwardEntryPointTest {
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
)
)
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
@Test
fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error"))
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
}
fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
eventIds: List<EventId> = listOf(AN_EVENT_ID),
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
eventIds = eventIds,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)
-1
View File
@@ -15,7 +15,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.features.forward.api)
implementation(projects.tests.testutils)
}
-2
View File
@@ -32,12 +32,10 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.features.logout.api)
implementation(projects.features.securebackup.api)
@@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToEnterRecoveryKey()
fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport()
fun navigateToAddAccount()
}
}
-6
View File
@@ -45,14 +45,11 @@ dependencies {
implementation(projects.libraries.indicator.api)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.fullscreenintent.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api)
@@ -72,11 +69,8 @@ dependencies {
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.indicator.test)
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
}
@@ -8,8 +8,8 @@
package io.element.android.features.home.impl
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.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder {
matrixUser
} else {
// Use the data from the DB
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
it.toMatrixUser()
}
}
.let { sessionList ->
@@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
}
@@ -221,6 +221,7 @@ class HomeFlowNode(
homeState = state,
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -19,18 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Inject
@@ -43,6 +48,8 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService,
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@@ -59,6 +66,30 @@ class HomePresenter(
}.collectAsState(initial = persistentListOf(matrixUser))
val isOnline by syncService.isOnline.collectAsState()
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val showMultiAccountAnnouncement by remember {
combine(
announcementService.announcementsToShowFlow(),
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount),
sessionStore.sessionsFlow(),
) { announcements, isMultiAccountEnabled, sessions ->
// Do not announce the feature to users who already have several accounts
isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount)
}
}.collectAsState(false)
LaunchedEffect(Unit) {
// The user already uses several accounts, so the feature does not need to be announced anymore,
// even if they later remove accounts.
combine(
announcementService.announcementsToShowFlow(),
sessionStore.sessionsFlow(),
) { announcements, sessions ->
announcements.contains(Announcement.MultiAccount) && sessions.size > 1
}
.filter { it }
.collect {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
@@ -82,6 +113,9 @@ class HomePresenter(
is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
}
@@ -95,6 +129,7 @@ class HomePresenter(
homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
eventSink = ::handleEvent,
)
}
@@ -28,6 +28,7 @@ data class HomeState(
val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
val eventSink: (HomeEvent) -> Unit,
) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -48,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState(
spaceRooms = emptyList(),
),
) + aHomeState(
showMultiAccountAnnouncement = true,
)
}
@@ -61,6 +63,7 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -68,6 +71,7 @@ internal fun aHomeState(
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
@@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.HomeTopBar
import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet
import io.element.android.features.home.impl.components.RoomListContentView
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -76,6 +77,7 @@ fun HomeView(
homeState: HomeState,
onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
@@ -110,6 +112,13 @@ fun HomeView(
)
}
if (homeState.showMultiAccountAnnouncement) {
MultiAccountAnnouncementBottomSheet(
onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) },
onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() },
)
}
leaveRoomView()
HomeScaffold(
@@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
homeState = state,
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
homeState = aHomeState(),
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 Element Creations 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.home.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
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.features.home.impl.R
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
/**
* Bottom sheet announcing that several accounts can now be added.
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MultiAccountAnnouncementBottomSheet(
onDismiss: () -> Unit,
onAddAccountClick: () -> Unit,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
scrollable = false,
) {
MultiAccountAnnouncementContent(
onAddAccountClick = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
onAddAccountClick()
}
},
)
}
}
@Composable
private fun MultiAccountAnnouncementContent(
onAddAccountClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
)
Spacer(Modifier.height(16.dp))
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics { heading() },
text = stringResource(R.string.screen_multi_account_announcement_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = annotatedTextWithBold(
text = stringResource(R.string.screen_multi_account_announcement_description, path),
boldText = path,
),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(40.dp))
Button(
text = stringResource(R.string.screen_multi_account_announcement_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
modifier = Modifier.fillMaxWidth(),
onClick = onAddAccountClick,
)
Spacer(Modifier.height(16.dp))
}
}
@PreviewsDayNight
@Composable
internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
MultiAccountAnnouncementBottomSheet(
onDismiss = {},
onAddAccountClick = {},
)
}
@@ -8,7 +8,6 @@
package io.element.android.features.home.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -23,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -129,6 +127,9 @@ private fun SkeletonView(
}
}
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656
*/
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
@@ -139,13 +140,19 @@ private fun EmptyView(
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
val text = buildString {
append(stringResource(R.string.screen_roomlist_empty_title))
append("\n")
append(stringResource(R.string.screen_roomlist_empty_message))
}
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
title = null,
subtitle = text,
bottomPart = {
Spacer(modifier = Modifier.height(16.dp))
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
leadingIcon = IconSource.Vector(CompoundIcons.Chat()),
onClick = onCreateRoomClick,
)
},
@@ -299,39 +306,44 @@ private fun EmptyViewForFilterStates(
) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
EmptyScaffold(
title = emptyStateResources.title,
subtitle = emptyStateResources.subtitle,
title = stringResource(emptyStateResources.title),
subtitle = stringResource(emptyStateResources.subtitle),
modifier = modifier,
bottomPart = {
// Keep a spacer so that the content is not too low on the screen
Spacer(modifier = Modifier.height(32.dp))
}
)
}
@Composable
private fun EmptyScaffold(
@StringRes title: Int,
@StringRes subtitle: Int,
title: String?,
subtitle: String,
modifier: Modifier = Modifier,
action: @Composable (ColumnScope.() -> Unit)? = null,
bottomPart: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
title?.let {
Text(
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = stringResource(title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(subtitle),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
action?.invoke(this)
bottomPart()
}
}
@@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
ElementTheme.colors.textActionAccent
} else {
ElementTheme.colors.roomListRoomMessageDate
},
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread
Row(
modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis
)
if (!isInviteSeen) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
UnreadIndicatorAtom(count = 0)
}
}
}
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
@@ -47,6 +47,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -119,7 +120,7 @@ class RoomListPresenter(
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
val showAllActivity by produceState(initialValue = false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it }
featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature).collect { value = it }
}
fun handleEvent(event: RoomListEvent) {
@@ -10,12 +10,14 @@ package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.search.history.SearchHistoryResult
@@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.search.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
import io.element.android.libraries.matrix.api.search.MessageSearchResult
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@@ -81,6 +84,7 @@ class GlobalSearchPresenter(
val isEnabled by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
}
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
@@ -88,6 +92,8 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
StartSearchBackfillWhenVisible(isEnabled = isEnabled, isSearchActive = isSearchActive)
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
@@ -297,6 +303,35 @@ class GlobalSearchPresenter(
)
}
@Composable
private fun StartSearchBackfillWhenVisible(
isEnabled: Boolean,
isSearchActive: Boolean,
) {
val coroutineScope = rememberCoroutineScope()
var hasBackfillRun by rememberSaveable { mutableStateOf(false) }
if (isEnabled && isSearchActive && !hasBackfillRun) {
DisposableEffect(Unit) {
// When we open the message search, we want to backfill the search results so that we can show the user more results if they scroll down.
val handle = matrixClient.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrNull()
?: return@DisposableEffect onDispose {
Timber.w("Could not start search backfill, it may already be running.")
}
val job = coroutineScope.launch(coroutineDispatchers.io) {
while (handle.isRunning()) {
delay(100.milliseconds)
}
hasBackfillRun = true
}
onDispose {
job.cancel()
handle.close()
}
}
}
}
private fun mapMediaContent(
result: MessageSearchResult,
roomInfo: RoomInfo,
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"Penyimpanan kunci Anda tidak sinkron"</string>
<string name="full_screen_intent_banner_message">"Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci."</string>
<string name="full_screen_intent_banner_title">"Tingkatkan pengalaman panggilan Anda"</string>
<string name="screen_home_tab_chats">"Semua Obrolan"</string>
<string name="screen_home_tab_chats">"Obrolan"</string>
<string name="screen_home_tab_spaces">"Space"</string>
<string name="screen_invites_decline_chat_message">"Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Tolak undangan"</string>
@@ -46,7 +46,7 @@ Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapa
<string name="screen_roomlist_filter_unreads_empty_state_title">"Selamat!
Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Permintaan untuk bergabung dikirim"</string>
<string name="screen_roomlist_main_space_title">"Semua Obrolan"</string>
<string name="screen_roomlist_main_space_title">"Obrolan"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ruangan ini telah ditingkatkan"</string>
@@ -24,6 +24,9 @@
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_multi_account_announcement_action">"Add account"</string>
<string name="screen_multi_account_announcement_description">"Go to %1$s to sign into another account. Then you can easily switch between them."</string>
<string name="screen_multi_account_announcement_title">"You can now add multiple accounts"</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
<string name="screen_roomlist_clear_filters">"Clear filters"</string>
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
@@ -52,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToEnterRecoveryKey() = lambdaError()
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -9,12 +9,18 @@
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
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.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
@@ -30,6 +36,8 @@ import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -144,6 +152,99 @@ class HomePresenterTest {
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
}
}
@Test
fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
assertThat(awaitItem().showMultiAccountAnnouncement).isFalse()
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue()
}
}
@Test
fun `present - multi account announcement is not shown when the feature is disabled`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to false),
),
)
presenter.test {
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
}
}
@Test
fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = "@alice:server.org"),
aSessionData(sessionId = "@bob:server.org"),
),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.MultiAccount))
}
}
@Test
fun `present - dismiss multi account announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
)
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = announcementService,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
val state = expectMostRecentItem()
assertThat(state.showMultiAccountAnnouncement).isTrue()
state.eventSink(HomeEvent.DismissMultiAccountAnnouncement)
announcementService.emitAnnouncementsToShow(emptyList())
assertThat(awaitItem().showMultiAccountAnnouncement).isFalse()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.MultiAccount))
}
}
}
internal fun createHomePresenter(
@@ -154,6 +255,8 @@ internal fun createHomePresenter(
indicatorService: IndicatorService = FakeIndicatorService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = HomePresenter(
client = client,
syncService = syncService,
@@ -163,4 +266,6 @@ internal fun createHomePresenter(
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
announcementService = announcementService,
featureFlagService = featureFlagService,
)
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
@@ -32,7 +33,6 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@@ -40,6 +40,7 @@ import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
@@ -655,7 +656,7 @@ class RoomListPresenterTest {
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.ShowAllActivityInRoomList.key to true,
ShowAllActivityInRoomListFeature.key to true,
FeatureFlags.UnreadIndicatorCount.key to true,
)
)
@@ -672,7 +673,7 @@ class RoomListPresenterTest {
assertThat(enabledState.contentAsRooms().showAllActivity).isTrue()
assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, false)
val allActivityDisabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity
}.last()
@@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.HomeEvent
import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.aHomeState
@@ -259,6 +260,23 @@ class RoomListViewTest : RobolectricTest() {
)
)
}
@Test
fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<HomeEvent>()
ensureCalledOnce { callback ->
setRoomListView(
state = aRoomListState(),
showMultiAccountAnnouncement = true,
homeEventSink = eventsRecorder,
onAddAccountClick = callback,
)
clickOn(R.string.screen_multi_account_announcement_action)
// Wait for the bottom sheet to be hidden
waitForIdle()
}
eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
@@ -273,12 +291,20 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
showMultiAccountAnnouncement: Boolean = false,
homeEventSink: (HomeEvent) -> Unit = {},
) {
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
homeState = aHomeState(
roomListState = state,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
eventSink = homeEventSink,
),
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
@@ -33,13 +33,16 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeSdkPendingTask
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.search.FakeMessageSearch
import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
@@ -205,7 +208,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -232,7 +237,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -267,7 +274,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -432,6 +441,44 @@ class GlobalSearchPresenterTest {
assertThat(awaitItem().results.isLoading()).isTrue()
}
}
@Test
fun `present - will automatically start search backfill and it will be cancelled when the presenter is closed`() = runTest {
var isRunning = false
val closeLambda = lambdaRecorder<Unit> { isRunning = false }
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(
startSearchBackfillLambda = {
isRunning = true
Result.success(FakeSdkPendingTask(isRunningLambda = { isRunning }, closeLambda = closeLambda))
},
),
)
val presenter = createGlobalSearchPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MessageSearch.key to true),
),
)
presenter.test {
// Await the initial state, with the feature enabled
val initialState = consumeItemsUntilPredicate { it.isEnabled }.last()
assertThat(isRunning).isFalse()
// Opening the search starts the backfill
initialState.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
consumeItemsUntilPredicate { it.isSearchActive }
// The backfill polling loop never ends while the task is running, so don't wait until the scheduler is idle
testScheduler.runCurrent()
assertThat(isRunning).isTrue()
closeLambda.assertions().isNeverCalled()
// Closing the presenter closes the task
cancelAndIgnoreRemainingEvents()
closeLambda.assertions().isCalledOnce()
assertThat(isRunning).isFalse()
}
}
}
private fun TestScope.createGlobalSearchPresenter(
@@ -442,7 +489,8 @@ private fun TestScope.createGlobalSearchPresenter(
dateFormatter: DateFormatter = FakeDateFormatter(),
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(),
matrixClient: MatrixClient =
FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) })),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter {
return GlobalSearchPresenter(
-3
View File
@@ -18,7 +18,4 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api)
}
-1
View File
@@ -32,7 +32,6 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
@@ -39,12 +39,10 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(libs.coil.compose)
implementation(projects.services.apperror.api)
implementation(projects.libraries.featureflag.api)
api(projects.features.invitepeople.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.services.apperror.test)
testImplementation(projects.libraries.featureflag.test)
}
-3
View File
@@ -37,12 +37,9 @@ dependencies {
implementation(projects.features.invite.api)
implementation(projects.features.roomdirectory.api)
implementation(projects.services.analytics.api)
implementation(projects.libraries.preferences.api)
implementation(projects.appconfig)
testCommonDependencies(libs, true)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.previewutils)
}
@@ -30,7 +30,6 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.designsystem)
@@ -17,6 +17,5 @@ android {
dependencies {
implementation(projects.features.knockrequests.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}
-2
View File
@@ -15,6 +15,4 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}
-1
View File
@@ -31,5 +31,4 @@ dependencies {
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(projects.libraries.matrix.test)
}
@@ -28,38 +28,24 @@ setupDependencyInjection()
dependencies {
// TODO Cleanup
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
implementation(libs.androidx.webkit)
implementation(libs.serialization.json)
api(projects.features.linknewdevice.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.linknewdevice.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
}
@@ -14,6 +14,5 @@ android {
}
dependencies {
implementation(projects.features.linknewdevice.api)
implementation(projects.tests.testutils)
}
-3
View File
@@ -68,12 +68,9 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.enterprise.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
testCommonDependencies(libs)
}
-5
View File
@@ -42,14 +42,11 @@ dependencies {
implementation(projects.services.appnavstate.api)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
@@ -58,10 +55,8 @@ dependencies {
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.enterprise.test)
}
@@ -140,7 +140,7 @@ fun MapBottomSheetScaffold(
isReady && mapState != null -> {
MaplibreMap(
state = mapState,
cameraPadding = sheetPadding,
viewportInsets = sheetPadding,
renderOptions = renderOptions,
interactions = interactions,
uiOptions = uiOptions,
@@ -8,33 +8,28 @@
package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import org.maplibre.compose.layers.LocationIndicatorDefaults
import org.maplibre.compose.layers.LocationIndicatorLayer
import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.spatialk.units.Length
@Composable
fun UserLocationPuck(
location: LocationMeasurement?,
) {
LocationPuck(
idPrefix = "user-location",
location = location,
// Hide the accuracy circle.
accuracyThreshold = Length.PositiveInfinity,
// Hide the bearing (and bearing accuracy) indicator.
LocationIndicatorLayer(
id = "user-location",
location = location?.position,
// Hide the accuracy circle and the bearing accuracy sector: leaving `accuracyRadius`
// and `bearingAccuracy` null is what hides them.
accuracyRadius = null,
bearingAccuracy = null,
// Hide the bearing indicator.
bearing = null,
sizes = LocationPuckSizes(
dotRadius = 8.dp,
dotStrokeWidth = 2.dp,
bearingImage = null,
topImage = LocationIndicatorDefaults.topImage(
color = ElementTheme.colors.iconAccentPrimary,
borderColor = ElementTheme.colors.bgCanvasDefault,
),
colors = LocationPuckColors(
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
)
)
}
@@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import io.element.android.features.location.impl.common.MapDefaults
import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.map.MapState
import kotlin.math.abs
@@ -77,7 +78,7 @@ internal fun UserLocationTrackingEffect(
zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
if (hasPositioned) {
mapState.animateCameraPosition(position)
mapState.animateCamera(position.toCameraUpdate(), CameraAnimation.Fly())
} else {
mapState.setCameraPosition(position)
hasPositioned = true
@@ -54,6 +54,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.map.MapState
@@ -149,7 +150,7 @@ private fun ShowLocationViewContent(
zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
mapState.animateCameraPosition(position = position)
mapState.animateCamera(update = position.toCameraUpdate(), animation = CameraAnimation.Fly())
}
},
modifier = modifier,
@@ -32,12 +32,10 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiCommon)
@@ -52,7 +50,6 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
-4
View File
@@ -71,14 +71,11 @@ dependencies {
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
implementation(libs.androidx.webkit)
implementation(libs.serialization.json)
api(projects.features.login.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
testImplementation(projects.libraries.preferences.test)
@@ -86,7 +83,6 @@ dependencies {
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(libs.androidx.camera.camera2)
testImplementation(libs.androidx.camera.lifecycle)
}
@@ -89,7 +89,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s tidak didukung"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Siap untuk memindai"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Pindai kode QR dengan perangkat ini"</string>
-3
View File
@@ -14,8 +14,5 @@ android {
}
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}
-3
View File
@@ -29,20 +29,17 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.workmanager.api)
api(projects.features.logout.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.workmanager.test)
}
-2
View File
@@ -19,7 +19,5 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.preferences.api)
api(projects.libraries.textcomposer.impl)
}

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