Merge branch 'develop' into feature/fga/room_list_api

This commit is contained in:
ganfra
2023-06-23 18:14:09 +02:00
114 changed files with 2437 additions and 170 deletions

View File

@@ -198,7 +198,6 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests)
implementation(projects.anvilannotations)
implementation(projects.appnav)

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
@@ -33,6 +34,7 @@ import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
import io.element.android.x.di.AppBindings
import timber.log.Timber
@@ -42,11 +44,13 @@ class MainActivity : NodeComponentActivity() {
private lateinit var mainNode: MainNode
private lateinit var appBindings: AppBindings
override fun onCreate(savedInstanceState: Bundle?) {
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
installSplashScreen()
super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
appBindings = bindings<AppBindings>()
appBindings.matrixClientsHolder().restore(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
@@ -57,25 +61,29 @@ class MainActivity : NodeComponentActivity() {
@Composable
private fun MainContent(appBindings: AppBindings) {
ElementTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
}
}
}
)
)
)
}
}
}
}

View File

@@ -18,10 +18,12 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface AppBindings {
fun matrixClientsHolder(): MatrixClientsHolder
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
fun snackbarDispatcher(): SnackbarDispatcher
}

View File

@@ -274,8 +274,13 @@ class LoggedInFlowNode @AssistedInject constructor(
}
} else {
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
val callback = object : RoomFlowNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) }
}
}
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
}
}
NavTarget.Settings -> {

View File

@@ -38,6 +38,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@@ -66,6 +67,10 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins,
) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
@@ -77,6 +82,7 @@ class RoomFlowNode @AssistedInject constructor(
) : NodeInputs
private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
init {
lifecycle.subscribe(
@@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
override fun onUserDataClicked(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
messagesEntryPoint.createNode(this, buildContext, callback)
}

1
changelog.d/486.feature Normal file
View File

@@ -0,0 +1 @@
Allow forawrding messages from one room to another

1
changelog.d/489.feature Normal file
View File

@@ -0,0 +1 @@
Add option to report inappropriate content

1
changelog.d/627.feature Normal file
View File

@@ -0,0 +1 @@
Add analytics events for room creation

1
changelog.d/663.feature Normal file
View File

@@ -0,0 +1 @@
Add 'Copy' action to timeline item context menu, for text events

View File

@@ -31,6 +31,7 @@ class FakeAnalyticsService(
private var isEnabledFlow = MutableStateFlow(isEnabled)
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
var capturedEvents = mutableListOf<VectorAnalyticsEvent>()
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
@@ -55,6 +56,7 @@ class FakeAnalyticsService(
}
override fun capture(event: VectorAnalyticsEvent) {
capturedEvents += event
}
override fun screen(screen: VectorAnalyticsScreen) {

View File

@@ -49,6 +49,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
api(projects.features.createroom.api)
@@ -59,6 +60,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.features.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)

View File

@@ -18,23 +18,35 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class)
class ConfigureRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom))
}
)
}
interface Callback : Plugin {
fun onCreateRoomSuccess(roomId: RoomId)
}
@@ -50,7 +62,7 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackPressed = this::navigateUp,
onRoomCreated = this::onRoomCreated
onRoomCreated = this::onRoomCreated,
)
}
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.libraries.architecture.Async
@@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -49,6 +51,7 @@ class ConfigureRoomPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService,
) : Presenter<ConfigureRoomState> {
@Composable
@@ -124,7 +127,10 @@ class ConfigureRoomPresenter @Inject constructor(
avatar = avatarUrl,
)
matrixClient.createRoom(params).getOrThrow()
.also { dataStore.clearCachedData() }
.also {
dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false))
}
}.execute(createRoomAction)
}

View File

@@ -20,12 +20,14 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.core.meta.BuildMeta
@@ -34,6 +36,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.ui.strings.R
import io.element.android.services.analytics.api.AnalyticsService
import timber.log.Timber
@ContributesNode(SessionScope::class)
@@ -43,6 +46,7 @@ class CreateRoomRootNode @AssistedInject constructor(
private val presenter: CreateRoomRootPresenter,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
@@ -60,6 +64,12 @@ class CreateRoomRootNode @AssistedInject constructor(
}
}
init {
lifecycle.subscribe(
onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) }
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -70,7 +80,7 @@ class CreateRoomRootNode @AssistedInject constructor(
onClosePressed = this::navigateUp,
onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onStartChatSuccess,
onInviteFriendsClicked = { invitePeople(context) }
onInviteFriendsClicked = { invitePeople(context) },
)
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter
@@ -28,10 +29,12 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenterArg
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -41,6 +44,8 @@ class CreateRoomRootPresenter @Inject constructor(
private val userRepository: UserRepository,
private val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
private val buildMeta: BuildMeta,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
@@ -79,6 +84,7 @@ class CreateRoomRootPresenter @Inject constructor(
}
return CreateRoomRootState(
applicationName = buildMeta.applicationName,
userListState = userListState,
startDmAction = startDmAction.value,
eventSink = ::handleEvents,
@@ -88,6 +94,7 @@ class CreateRoomRootPresenter @Inject constructor(
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
suspend {
matrixClient.createDM(user.userId).getOrThrow()
.also { analyticsService.capture(CreatedRoom(isDM = true)) }
}.execute(startDmAction)
}
}

View File

@@ -21,6 +21,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState(
val applicationName: String,
val userListState: UserListState,
val startDmAction: Async<RoomId>,
val eventSink: (CreateRoomRootEvents) -> Unit,

View File

@@ -22,6 +22,7 @@ import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
@@ -33,7 +34,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
@@ -44,7 +45,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
@@ -55,6 +56,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
applicationName = "Element X Preview",
startDmAction = Async.Uninitialized,
userListState = aUserListState(),
)

View File

@@ -97,6 +97,7 @@ fun CreateRoomRootView(
if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList(
state = state,
onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = onInviteFriendsClicked,
)
@@ -155,6 +156,7 @@ fun CreateRoomRootViewTopBar(
@Composable
fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
modifier: Modifier = Modifier,
onNewRoomClicked: () -> Unit = {},
onInvitePeopleClicked: () -> Unit = {},
@@ -167,7 +169,7 @@ fun CreateRoomActionButtonsList(
)
CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_share,
text = stringResource(id = R.string.screen_create_room_action_invite_people),
text = stringResource(id = StringR.string.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked,
)
}

View File

@@ -21,6 +21,8 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
@@ -62,6 +64,7 @@ class ConfigureRoomPresenterTests {
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService
@Before
fun setup() {
@@ -70,11 +73,13 @@ class ConfigureRoomPresenterTests {
createRoomDataStore = CreateRoomDataStore(userListDataStore)
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService()
presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService,
)
mockkStatic(File::readBytes)
@@ -214,6 +219,25 @@ class ConfigureRoomPresenterTests {
}
}
@Test
fun `present - record analytics when creating room`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isFalse()
}
}
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
@@ -229,6 +253,7 @@ class ConfigureRoomPresenterTests {
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))

View File

@@ -20,11 +20,15 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -43,17 +47,21 @@ class CreateRoomRootPresenterTests {
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakeAnalyticsService: FakeAnalyticsService
@Before
fun setup() {
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
fakeAnalyticsService = FakeAnalyticsService()
userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
userRepository = userRepository,
userListDataStore = UserListDataStore(),
matrixClient = fakeMatrixClient
matrixClient = fakeMatrixClient,
analyticsService = fakeAnalyticsService,
buildMeta = aBuildMeta(),
)
}
@@ -63,7 +71,11 @@ class CreateRoomRootPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState)
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
}
}
@@ -87,6 +99,27 @@ class CreateRoomRootPresenterTests {
}
}
@Test
fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isTrue()
}
}
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
@@ -102,6 +135,7 @@ class CreateRoomRootPresenterTests {
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
}
}
@@ -124,6 +158,7 @@ class CreateRoomRootPresenterTests {
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
// Cancel
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
@@ -135,6 +170,7 @@ class CreateRoomRootPresenterTests {
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterSecondAttempt = awaitItem()
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
// Retry with success
fakeMatrixClient.givenCreateDmError(null)
@@ -147,3 +183,18 @@ class CreateRoomRootPresenterTests {
}
}
}
private fun aBuildMeta() =
BuildMeta(
buildType = BuildType.DEBUG,
isDebuggable = true,
applicationId = "",
applicationName = "An Application",
lowPrivacyLoggingEnabled = true,
versionName = "",
gitRevision = "",
gitBranchName = "",
gitRevisionDate = "",
flavorDescription = "",
flavorShortDescription = "",
)

View File

@@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
interface MessagesEntryPoint : FeatureEntryPoint {
@@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId)
fun onForwardedToSingleRoom(roomId: RoomId)
}
}

View File

@@ -32,17 +32,21 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
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.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -78,6 +82,12 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
@Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@@ -105,6 +115,14 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun onForwardEventClicked(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId))
}
override fun onReportMessage(eventId: EventId, senderId: UserId) {
backstack.push(NavTarget.ReportMessage(eventId, senderId))
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
@@ -124,6 +142,19 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
}
is NavTarget.ForwardEvent -> {
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
val callback = object : ForwardMessagesNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId)
}
}
createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback))
}
is NavTarget.ReportMessage -> {
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs))
}
}
}
@@ -179,6 +210,7 @@ class MessagesFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
interface MessagesNavigator {
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportContentClicked(eventId: EventId, senderId: UserId)
}

View File

@@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterFactory: MessagesPresenter.Factory,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull()
interface Callback : Plugin {
@@ -48,6 +49,8 @@ class MessagesNode @AssistedInject constructor(
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClicked(userId: UserId)
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
}
private fun onRoomDetailsClicked() {
@@ -65,11 +68,18 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
override fun onForwardEventClicked(eventId: EventId) {
callback?.onForwardEventClicked(eventId)
}
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
callback?.onReportMessage(eventId, senderId)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -80,7 +90,6 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onItemDebugInfoClicked = this::onShowEventDebugInfoClicked,
modifier = modifier,
)
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -25,6 +26,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@@ -47,11 +51,13 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -63,9 +69,8 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class MessagesPresenter @Inject constructor(
class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
@@ -76,8 +81,15 @@ class MessagesPresenter @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
@Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessagesPresenter
}
@Composable
override fun present(): MessagesState {
val localCoroutineScope = rememberCoroutineScope()
@@ -146,13 +158,13 @@ class MessagesPresenter @Inject constructor(
composerState: MessageComposerState,
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
TimelineItemAction.Forward -> notImplementedYet()
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> Unit // Handled at UI level
TimelineItemAction.ReportContent -> notImplementedYet()
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
}
}
@@ -222,4 +234,33 @@ class MessagesPresenter @Inject constructor(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
}
private fun handleForwardAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.onForwardEventClicked(event.eventId)
}
private fun handleReportAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.onReportContentClicked(event.eventId, event.senderId)
}
private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body
is TimelineItemStateContent -> event.content.body
else -> return
}
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_message_copied))
}
}
}

View File

@@ -33,8 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
@@ -69,18 +67,15 @@ import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
@@ -95,7 +90,6 @@ fun MessagesView(
onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@@ -121,12 +115,7 @@ fun MessagesView(
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
when (action) {
is TimelineItemAction.Developer -> if (event.eventId != null) {
onItemDebugInfoClicked(event.eventId, event.debugInfo)
}
else -> state.eventSink(MessagesEvents.HandleAction(action, event))
}
state.eventSink(MessagesEvents.HandleAction(action, event))
}
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
@@ -261,12 +250,7 @@ fun MessagesViewTopBar(
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
BackButton(onClick = onBackPressed)
},
title = {
Row(
@@ -331,6 +315,5 @@ private fun ContentToPreview(state: MessagesState) {
onEventClicked = {},
onPreviewAttachments = {},
onUserDataClicked = {},
onItemDebugInfoClicked = { _, _ -> },
)
}

View File

@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList
@@ -64,7 +65,9 @@ class ActionListPresenter @Inject constructor(
is TimelineItemRedactedContent,
is TimelineItemStateContent -> {
buildList {
add(TimelineItemAction.Copy)
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
@@ -76,7 +79,9 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isMine) {
add(TimelineItemAction.Edit)
}
add(TimelineItemAction.Copy)
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
sealed interface ForwardMessagesEvents {
data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
// TODO remove to restore multi-selection
object RemoveSelectedRoom : ForwardMessagesEvents
object ToggleSearchActive : ForwardMessagesEvents
data class UpdateQuery(val query: String) : ForwardMessagesEvents
object ForwardEvent : ForwardMessagesEvents
object ClearError : ForwardMessagesEvents
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
class ForwardMessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Inputs(val eventId: EventId) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.eventId.value)
private val callbacks = plugins.filterIsInstance<Callback>()
private fun onSucceeded(roomIds: ImmutableList<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ForwardMessagesView(
state = state,
onDismiss = ::navigateUp,
onForwardingSucceeded = ::onSucceeded,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClient
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.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
private val room: MatrixRoom,
private val matrixCoroutineScope: CoroutineScope,
private val client: MatrixClient,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
interface Factory {
fun create(eventId: String): ForwardMessagesPresenter
}
@Composable
override fun present(): ForwardMessagesState {
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf<String>("") }
var isSearchActive by remember { mutableStateOf(false) }
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = remember { mutableStateOf(Async.Uninitialized) }
val summaries by client.roomSummaryDataSource.roomList().collectAsState()
LaunchedEffect(query, summaries) {
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
.map { it.details }
.filter { it.name.contains(query, ignoreCase = true) }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
.toPersistentList()
results = if (filteredSummaries.isNotEmpty()) {
SearchBarResultState.Results(filteredSummaries)
} else {
SearchBarResultState.NoResults()
}
}
val forwardingSucceeded by remember {
derivedStateOf { forwardingActionState.value.dataOrNull() }
}
fun handleEvents(event: ForwardMessagesEvents) {
when (event) {
is ForwardMessagesEvents.SetSelectedRoom -> {
selectedRooms = persistentListOf(event.room)
// Restore for multi-selection
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
// selectedRooms = if (index >= 0) {
// selectedRooms.removeAt(index)
// } else {
// selectedRooms.add(event.room)
// }
}
ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
is ForwardMessagesEvents.UpdateQuery -> query = event.query
ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
ForwardMessagesEvents.ForwardEvent -> {
isSearchActive = false
val roomIds = selectedRooms.map { it.roomId }.toPersistentList()
matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState)
}
ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
}
}
return ForwardMessagesState(
resultState = results,
query = query,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
isForwarding = forwardingActionState.value.isLoading(),
error = (forwardingActionState.value as? Async.Failure)?.error,
forwardingSucceeded = forwardingSucceeded,
eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.forwardEvent(
eventId: EventId,
roomIds: ImmutableList<RoomId>,
isForwardMessagesState: MutableState<Async<ImmutableList<RoomId>>>,
) = launch {
isForwardMessagesState.value = Async.Loading()
room.forwardEvent(eventId, roomIds).fold(
{ isForwardMessagesState.value = Async.Success(roomIds) },
{ isForwardMessagesState.value = Async.Failure(it) }
)
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import kotlinx.collections.immutable.ImmutableList
data class ForwardMessagesState(
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>,
val query: String,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<RoomSummaryDetails>,
val isForwarding: Boolean,
val error: Throwable?,
val forwardingSucceeded: ImmutableList<RoomId>?,
val eventSink: (ForwardMessagesEvents) -> Unit
)

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
override val values: Sequence<ForwardMessagesState>
get() = sequenceOf(
aForwardMessagesState(),
aForwardMessagesState(query = "Test"),
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"),
aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain")))
),
aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
isForwarding = true,
),
aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
forwardingSucceeded = persistentListOf(RoomId("!room2:domain")),
),
aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
error = Throwable("error"),
),
// Add other states here
)
}
fun aForwardMessagesState(
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
isForwarding: Boolean = false,
error: Throwable? = null,
forwardingSucceeded: ImmutableList<RoomId>? = null,
) = ForwardMessagesState(
resultState = resultState,
query = query,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
isForwarding = isForwarding,
error = error,
forwardingSucceeded = forwardingSucceeded,
eventSink = {}
)
internal fun aForwardMessagesRoomList() = listOf(
aRoomDetailsState(),
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"),
)
fun aRoomDetailsState(
roomId: RoomId = RoomId("!room:domain"),
name: String = "roomName",
canonicalAlias: String? = null,
isDirect: Boolean = true,
avatarURLString: String? = null,
lastMessage: RoomMessage? = null,
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 0,
inviter: RoomMember? = null,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarURLString = avatarURLString,
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
inviter = inviter,
)

View File

@@ -0,0 +1,292 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.forward
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ForwardMessagesView(
state: ForwardMessagesState,
onDismiss: () -> Unit,
onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit,
modifier: Modifier = Modifier,
) {
if (state.forwardingSucceeded != null) {
onForwardingSucceeded(state.forwardingSucceeded)
return
}
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
// TODO toggle selection when multi-selection is enabled
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
}
@Composable
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) {
if (isForwarding) return
SelectedRooms(
selectedRooms = selectedRooms,
onRoomRemoved = ::onRoomRemoved,
modifier = Modifier.padding(vertical = 16.dp)
)
}
fun onBackButton(state: ForwardMessagesState) {
if (state.isSearchActive) {
state.eventSink(ForwardMessagesEvents.ToggleSearchActive)
} else {
onDismiss()
}
}
BackHandler(onBack = { onBackButton(state) })
Scaffold(
modifier = modifier,
topBar = {
CenterAlignedTopAppBar(
title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) },
navigationIcon = {
BackButton(onClick = { onBackButton(state) })
},
actions = {
TextButton(
enabled = state.selectedRooms.isNotEmpty(),
onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) }
) {
Text(text = stringResource(StringR.string.action_send))
}
}
)
}
) { paddingValues ->
Column(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
SearchBar<ImmutableList<RoomSummaryDetails>>(
placeHolderTitle = stringResource(StringR.string.action_search),
query = state.query,
onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
active = state.isSearchActive,
onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) },
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn {
item {
SelectedRoomsHelper(
isForwarding = state.isForwarding,
selectedRooms = state.selectedRooms
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
}
)
Divider(modifier = Modifier.fillMaxWidth())
}
}
}
}
if (!state.isSearchActive) {
// TODO restore for multi-selection
// SelectedRoomsHelper(
// isForwarding = state.isForwarding,
// selectedRooms = state.selectedRooms
// )
Spacer(modifier = Modifier.height(20.dp))
if (state.resultState is SearchBarResultState.Results) {
LazyColumn {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
}
)
Divider(modifier = Modifier.fillMaxWidth())
}
}
}
}
}
if (state.isForwarding) {
ProgressDialog()
}
if (state.error != null) {
ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) })
}
}
}
}
@Composable
internal fun SelectedRooms(
selectedRooms: ImmutableList<RoomSummaryDetails>,
onRoomRemoved: (RoomSummaryDetails) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
modifier,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
items(selectedRooms, key = { it.roomId.value }) { roomSummary ->
SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved)
}
}
}
@Composable
internal fun RoomSummaryView(
summary: RoomSummaryDetails,
isSelected: Boolean,
onSelection: (RoomSummaryDetails) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clickable { onSelection(summary) }
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
val roomAlias = summary.canonicalAlias ?: summary.roomId.value
Avatar(
avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
.alignByBaseline()
.weight(1f)
) {
// Name
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = summary.name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Id
Text(
text = roomAlias,
color = MaterialTheme.roomListRoomMessage(),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
RadioButton(selected = isSelected, onClick = { onSelection(summary) })
}
}
@Composable
private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
ErrorDialog(
content = ErrorDialogDefaults.title,
onDismiss = onDismiss,
modifier = modifier,
)
}
@Preview
@Composable
fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ForwardMessagesState) {
ForwardMessagesView(
state = state,
onDismiss = {},
onForwardingSucceeded = {}
)
}

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
class ReportMessagePresenter @AssistedInject constructor(
private val room: MatrixRoom,
@Assisted private val inputs: Inputs,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<ReportMessageState> {
data class Inputs(
val eventId: EventId,
val senderId: UserId,
)
@AssistedFactory
interface Factory {
fun create(inputs: Inputs): ReportMessagePresenter
}
@Composable
override fun present(): ReportMessageState {
val coroutineScope = rememberCoroutineScope()
var reason by rememberSaveable { mutableStateOf("") }
var blockUser by rememberSaveable { mutableStateOf(false) }
var result: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
fun handleEvents(event: ReportMessageEvents) {
when (event) {
is ReportMessageEvents.UpdateReason -> reason = event.reason
ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser
ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result)
ReportMessageEvents.ClearError -> result.value = Async.Uninitialized
}
}
return ReportMessageState(
reason = reason,
blockUser = blockUser,
result = result.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.report(
eventId: EventId,
userId: UserId,
reason: String,
blockUser: Boolean,
result: MutableState<Async<Unit>>,
) = launch {
suspend {
val userIdToBlock = userId.takeIf { blockUser }
room.reportContent(eventId, reason, userIdToBlock)
.onSuccess {
snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted))
}
}.executeResult(result)
}
}

View File

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

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.report
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageState> {
override val values: Sequence<ReportMessageState>
get() = sequenceOf(
aReportMessageState(),
aReportMessageState(reason = "This user is making the chat very toxic."),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable())),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)),
// Add other states here
)
}
fun aReportMessageState(
reason: String = "",
blockUser: Boolean = false,
result: Async<Unit> = Async.Uninitialized,
) = ReportMessageState(
reason = reason,
blockUser = blockUser,
result = result,
eventSink = {}
)

View File

@@ -0,0 +1,186 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.report
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
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.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ReportMessageView(
state: ReportMessageState,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val isSending = state.result is Async.Loading
when (state.result) {
is Async.Success -> {
LaunchedEffect(state.result) {
onBackClicked()
}
return
}
is Async.Failure -> {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
)
}
else -> Unit
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
stringResource(StringR.string.action_report_content),
style = ElementTextStyles.Regular.callout,
fontWeight = FontWeight.Medium,
)
},
navigationIcon = {
BackButton(onClick = onBackClicked)
}
)
},
modifier = modifier
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = state.reason,
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
placeholder = { Text(stringResource(StringR.string.report_content_hint)) },
enabled = !isSending,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 90.dp)
)
Text(
text = stringResource(StringR.string.report_content_explanation),
style = ElementTextStyles.Regular.caption1,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
modifier = Modifier.padding(top = 4.dp, bottom = 24.dp, start = 16.dp, end = 16.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(StringR.string.screen_report_content_block_user),
style = ElementTextStyles.Regular.callout,
)
Text(
text = stringResource(StringR.string.screen_report_content_block_user_hint),
style = ElementTextStyles.Regular.bodyMD,
color = MaterialTheme.colorScheme.secondary,
)
}
Switch(
enabled = !isSending,
checked = state.blockUser,
onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) },
)
}
Spacer(modifier = Modifier.height(24.dp))
ButtonWithProgress(
text = stringResource(StringR.string.action_send),
enabled = state.reason.isNotBlank() && !isSending,
showProgress = isSending,
onClick = {
focusManager.clearFocus(force = true)
state.eventSink(ReportMessageEvents.Report)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
)
}
}
}
@Preview
@Composable
fun ReportMessageViewLightPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ReportMessageViewDarkPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ReportMessageState) {
ReportMessageView(
onBackClicked = {},
state = state,
)
}

View File

@@ -22,3 +22,14 @@ import androidx.compose.runtime.Immutable
sealed interface TimelineItemEventContent {
val type: String
}
/**
* Only text based content and states can be copied.
*/
fun TimelineItemEventContent.canBeCopied(): Boolean =
when (this) {
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> true
else -> false
}

View File

@@ -12,6 +12,7 @@
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
<string name="screen_room_message_copied">"Message copied"</string>
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
class FakeMessagesNavigator : MessagesNavigator {
var onShowEventDebugInfoClickedCount = 0
private set
var onForwardEventClickedCount = 0
private set
var onReportContentClickedCount = 0
private set
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++
}
override fun onForwardEventClicked(eventId: EventId) {
onForwardEventClickedCount++
}
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
onReportContentClickedCount++
}
}

View File

@@ -34,10 +34,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@@ -100,7 +102,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action forward`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -108,19 +111,23 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
}
}
@Test
fun `present - handle action copy`() = runTest {
val presenter = createMessagePresenter()
val clipboardHelper = FakeClipboardHelper()
val event = aMessageEvent()
val presenter = createMessagePresenter(clipboardHelper = clipboardHelper)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent()))
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body)
}
}
@@ -282,7 +289,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action report content`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -290,6 +298,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
}
}
@@ -308,7 +317,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action show developer info`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -316,6 +326,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
}
}
@@ -347,7 +358,9 @@ class MessagesPresenterTest {
private fun TestScope.createMessagePresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom()
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@@ -388,6 +401,8 @@ class MessagesPresenterTest {
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
clipboardHelper = clipboardHelper,
dispatchers = coroutineDispatchers,
)
}

View File

@@ -25,8 +25,10 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.test.A_MESSAGE
@@ -164,6 +166,38 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for a media item`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Developer,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.forward
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.forward.ForwardMessagesEvents
import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ForwardMessagesPresenterTests {
@Test
fun `present - initial state`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.isForwarding).isFalse()
assertThat(initialState.error).isNull()
assertThat(initialState.forwardingSucceeded).isNull()
// Search is run automatically
val searchState = awaitItem()
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@Test
fun `present - toggle search active`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - update query`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().apply {
postRoomSummary(listOf(RoomSummary.Filled(aRoomSummaryDetail())))
}
val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
val presenter = aPresenter(client = client)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail())))
initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained"))
assertThat(awaitItem().query).isEqualTo("string not contained")
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@Test
fun `present - select a room and forward successful`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
awaitItem()
// Test successful forwarding
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
val forwardingState = awaitItem()
assertThat(forwardingState.isSearchActive).isFalse()
assertThat(forwardingState.isForwarding).isTrue()
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.isForwarding).isFalse()
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val room = FakeMatrixRoom()
val presenter = aPresenter(fakeMatrixRoom = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
awaitItem()
// Test failed forwarding
room.givenForwardEventResult(Result.failure(Throwable("error")))
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
skipItems(1)
val failedForwardState = awaitItem()
assertThat(failedForwardState.isForwarding).isFalse()
assertThat(failedForwardState.error).isNotNull()
// Then clear error
initialState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().error).isNull()
}
}
@Test
fun `present - select and remove a room`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
assertThat(awaitItem().selectedRooms).isEmpty()
}
}
private fun CoroutineScope.aPresenter(
eventId: EventId = AN_EVENT_ID,
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
coroutineScope: CoroutineScope = this,
client: FakeMatrixClient = FakeMatrixClient(),
) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client)
}

View File

@@ -69,7 +69,8 @@ class MediaViewerPresenterTest {
fun `present - check all actions `() = runTest {
val mediaLoader = FakeMediaLoader()
val mediaActions = FakeLocalMediaActions()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
val snackbarDispatcher = SnackbarDispatcher()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -90,26 +91,24 @@ class MediaViewerPresenterTest {
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
}
}
@@ -145,6 +144,7 @@ class MediaViewerPresenterTest {
private fun aMediaViewerPresenter(
mediaLoader: FakeMediaLoader,
localMediaActions: FakeLocalMediaActions,
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
@@ -155,7 +155,7 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher = snackbarDispatcher,
)
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.report
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.report.ReportMessageEvents
import io.element.android.features.messages.impl.report.ReportMessagePresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ReportMessagePresenterTests {
@Test
fun `presenter - initial state`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.reason).isEmpty()
assertThat(initialState.blockUser).isFalse()
assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java)
}
}
@Test
fun `presenter - update reason`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val reason = "This user is making the chat very toxic."
initialState.eventSink(ReportMessageEvents.UpdateReason(reason))
assertThat(awaitItem().reason).isEqualTo(reason)
}
}
@Test
fun `presenter - toggle block user`() = runTest {
val presenter = aPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
assertThat(awaitItem().blockUser).isTrue()
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
assertThat(awaitItem().blockUser).isFalse()
}
}
@Test
fun `presenter - handle successful report and block user`() = runTest {
val room = FakeMatrixRoom()
val presenter = aPresenter(matrixRoom = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
skipItems(1)
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
}
}
@Test
fun `presenter - handle successful report`() = runTest {
val room = FakeMatrixRoom()
val presenter = aPresenter(matrixRoom = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
}
}
@Test
fun `presenter - handle failed report`() = runTest {
val room = FakeMatrixRoom().apply {
givenReportContentResult(Result.failure(Exception("Failed to report content")))
}
val presenter = aPresenter(matrixRoom = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
val resultState = awaitItem()
assertThat(resultState.result).isInstanceOf(Async.Failure::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
resultState.eventSink(ReportMessageEvents.ClearError)
assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java)
}
}
private fun aPresenter(
inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
) = ReportMessagePresenter(
inputs = inputs,
room = matrixRoom,
snackbarDispatcher = snackbarDispatcher,
)
}

View File

@@ -4,6 +4,7 @@
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
<string name="screen_onboarding_welcome_message">"Welcome to the fastest Element ever. Supercharged for speed and simplicity."</string>
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your Element"</string>
<string name="screen_onboarding_welcome_title">"Be in your element"</string>
</resources>

View File

@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
private val presenter: RoomListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onRoomClicked(roomId: RoomId) {

View File

@@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.22"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.23"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@@ -158,7 +158,7 @@ statemachine = "com.freeletics.flowredux:compose:1.1.0"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
sentry_android = "io.sentry:sentry-android:6.23.0"
sentry_android = "io.sentry:sentry-android:6.24.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
# Di
@@ -190,7 +190,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = "org.jlleitschuh.gradle.ktlint:11.4.1"
ktlint = "org.jlleitschuh.gradle.ktlint:11.4.2"
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }

View File

@@ -16,18 +16,28 @@
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.androidutils"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(libs.dagger)
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.browser)
implementation(projects.libraries.core)
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidClipboardHelper @Inject constructor(
@ApplicationContext private val context: Context,
) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
override fun copyPlainText(text: String) {
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text))
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
/**
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
*/
interface ClipboardHelper {
fun copyPlainText(text: String)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
class FakeClipboardHelper : ClipboardHelper {
var clipboardContents: Any? = null
override fun copyPlainText(text: String) {
clipboardContents = text
}
}

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
@@ -52,6 +53,7 @@ fun ModalBottomSheet(
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
content: @Composable ColumnScope.() -> Unit,
) {
androidx.compose.material3.ModalBottomSheet(
@@ -64,6 +66,7 @@ fun ModalBottomSheet(
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
windowInsets = windowInsets,
content = content,
)
}

View File

@@ -22,13 +22,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -52,19 +53,16 @@ class SnackbarDispatcher {
}
}
/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */
val LocalSnackbarDispatcher = compositionLocalOf<SnackbarDispatcher> {
error("No SnackbarDispatcher provided")
}
@Composable
fun handleSnackbarMessage(
snackbarDispatcher: SnackbarDispatcher
): SnackbarMessage? {
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
LaunchedEffect(snackbarMessage) {
if (snackbarMessage != null) {
launch {
snackbarDispatcher.clear()
}
}
}
return snackbarMessage
return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value
}
@Composable
@@ -74,6 +72,7 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId)
}
val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect
coroutineScope.launch {
@@ -81,6 +80,9 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
if (isActive) {
dispatcher.clear()
}
}
}
return snackbarHostState

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.RoomId
class ForwardEventException(
val roomIds: List<RoomId>
) : Exception() {
override val message: String? = "Failed to deliver event to $roomIds rooms"
}

View File

@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
@@ -86,6 +87,8 @@ interface MatrixRoom : Closeable {
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit>
suspend fun retrySendMessage(transactionId: String): Result<Unit>
suspend fun cancelSend(transactionId: String): Result<Unit>
@@ -111,4 +114,6 @@ interface MatrixRoom : Closeable {
suspend fun setName(name: String): Result<Unit>
suspend fun setTopic(topic: String): Result<Unit>
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
}

View File

@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface EventSendState {
object NotSentYet : EventSendState
object Canceled : EventSendState
data class SendingFailed(
val error: String

View File

@@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.room.roomOrNull
@@ -79,7 +80,7 @@ class RustMatrixClient constructor(
override val sessionId: UserId = UserId(client.userId())
private val roomListService = client.roomList()
private val roomListService = client.roomListService()
private val sessionCoroutineScope = childScopeOf(appCoroutineScope, dispatchers.main, "Session-${sessionId}")
private val verificationService = RustSessionVerificationService()
private val syncService = RustSyncService(roomListService, sessionCoroutineScope)
@@ -112,6 +113,8 @@ class RustMatrixClient constructor(
private val roomMembershipObserver = RoomMembershipObserver()
private val roomContentForwarder = RoomContentForwarder(roomListService)
init {
client.setDelegate(clientDelegate)
syncService.syncState
@@ -132,7 +135,8 @@ class RustMatrixClient constructor(
innerRoom = fullRoom,
sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = dispatchers,
systemClock = clock
systemClock = clock,
roomContentForwarder = roomContentForwarder,
)
}

View File

@@ -35,7 +35,7 @@ class RustNotificationService(
eventId: EventId
): Result<NotificationData?> {
return runCatching {
client.getNotificationItem(roomId.value, eventId.value).use(notificationMapper::map)
client.getNotificationItem(roomId.value, eventId.value)?.use(notificationMapper::map)
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.parallelMap
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.room.ForwardEventException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.SlidingSync
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
import org.matrix.rustcomponents.sdk.genTransactionId
import kotlin.time.Duration.Companion.milliseconds
/**
* Helper to forward event contents from a room to a set of other rooms.
* @param slidingSync the [SlidingSync] to fetch room instances to forward the event to
*/
class RoomContentForwarder(
private val roomListService: RoomListService,
) {
/**
* Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds].
* @param fromRoom the room to forward the event from
* @param eventId the id of the event to forward
* @param toRoomIds the ids of the rooms to forward the event to
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
*/
suspend fun forward(
fromRoom: Room,
eventId: EventId,
toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L
) {
val content = fromRoom.getTimelineEventContentByEventId(eventId.value)
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
val result = runCatching {
// Sending a message requires a registered timeline listener
targetRoom.addTimelineListener(NoOpTimelineListener)
withTimeout(timeoutMs.milliseconds) {
targetRoom.send(content, genTransactionId())
}
}
// After sending, we remove the timeline
targetRoom.removeTimeline()
result
}.onFailure {
failedForwardingTo.add(RoomId(room.id()))
if (it is CancellationException) {
throw it
}
}
}
if (failedForwardingTo.isNotEmpty()) {
throw ForwardEventException(toRoomIds.toList())
}
}
private object NoOpTimelineListener : TimelineListener {
override fun onUpdate(diff: TimelineDiff) = Unit
}
}

View File

@@ -3,40 +3,31 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import org.matrix.rustcomponents.sdk.RoomList
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListState
import org.matrix.rustcomponents.sdk.RoomListStateListener
import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState
import org.matrix.rustcomponents.sdk.SlidingSyncListStateObserver
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceStateListener
import timber.log.Timber
fun RoomListInterface.roomListStateFlow(): Flow<RoomListState> =
fun RoomList.loadingStateFlow(): Flow<RoomListLoadingState> =
mxCallbackFlow {
val listener = object : RoomListStateListener {
override fun onUpdate(state: RoomListState) {
val listener = object : RoomListLoadingStateListener {
override fun onUpdate(state: RoomListLoadingState) {
trySendBlocking(state)
}
}
state(listener)
val result = loadingState(listener)
send(result.state)
result.stateStream
}
fun RoomListInterface.loadingStateFlow(): Flow<SlidingSyncListLoadingState> =
mxCallbackFlow {
val listener = object : SlidingSyncListStateObserver {
override fun didReceiveUpdate(newState: SlidingSyncListLoadingState) {
trySendBlocking(newState)
}
}
val result = entriesLoadingState(listener)
send(result.entriesLoadingState)
result.entriesLoadingStateStream
}
fun RoomListInterface.roomListEntriesUpdateFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit): Flow<RoomListEntriesUpdate> =
fun RoomList.entriesFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit): Flow<RoomListEntriesUpdate> =
mxCallbackFlow {
val listener = object : RoomListEntriesListener {
override fun onUpdate(roomEntriesUpdate: RoomListEntriesUpdate) {
@@ -48,7 +39,7 @@ fun RoomListInterface.roomListEntriesUpdateFlow(onInitialList: suspend (List<Roo
result.entriesStream
}
fun RoomListInterface.roomOrNull(roomId: String): RoomListItem? {
fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
return try {
room(roomId)
} catch (failure: Throwable) {
@@ -56,3 +47,13 @@ fun RoomListInterface.roomOrNull(roomId: String): RoomListItem? {
return null
}
}
fun RoomListService.stateFlow(): Flow<RoomListServiceState> =
mxCallbackFlow {
val listener = object : RoomListServiceStateListener {
override fun onUpdate(state: RoomListServiceState) {
trySendBlocking(state)
}
}
state(listener)
}

View File

@@ -55,6 +55,7 @@ import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
import java.io.File
class RustMatrixRoom(
@@ -64,6 +65,7 @@ class RustMatrixRoom(
sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
@@ -307,6 +309,14 @@ class RustMatrixRoom(
}
}
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
}
}
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
runCatching {
@@ -350,9 +360,19 @@ class RustMatrixRoom(
}
}
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()
}
}
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
if (blockUserId != null) {
innerRoom.ignoreUser(blockUserId.value)
}
}
}
}

View File

@@ -30,13 +30,13 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.RoomListException
import org.matrix.rustcomponents.sdk.RoomListInput
import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListRange
import org.matrix.rustcomponents.sdk.RoomListService
import timber.log.Timber
import java.util.UUID
internal class RustRoomSummaryDataSource(
private val roomListService: RoomListInterface,
private val roomListService: RoomListService,
private val sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
@@ -48,7 +48,7 @@ internal class RustRoomSummaryDataSource(
fun init() {
sessionCoroutineScope.launch(coroutineDispatchers.computation) {
roomListService.roomListEntriesUpdateFlow { roomListEntries ->
roomListService.allRooms().entriesFlow { roomListEntries ->
roomList.value = roomListEntries.map(::buildSummaryForRoomListEntry)
}.onEach { update ->
roomList.getAndUpdate {

View File

@@ -17,14 +17,14 @@
package io.element.android.libraries.matrix.impl.sync
import io.element.android.libraries.matrix.api.sync.SyncState
import org.matrix.rustcomponents.sdk.RoomListState
import org.matrix.rustcomponents.sdk.RoomListServiceState
internal fun RoomListState.toSyncState(): SyncState {
internal fun RoomListServiceState.toSyncState(): SyncState {
return when (this) {
RoomListState.INIT,
RoomListState.SETTING_UP -> SyncState.Idle
RoomListState.RUNNING -> SyncState.Syncing
RoomListState.ERROR -> SyncState.InError
RoomListState.TERMINATED -> SyncState.Terminated
RoomListServiceState.INIT,
RoomListServiceState.SETTING_UP -> SyncState.Idle
RoomListServiceState.RUNNING -> SyncState.Syncing
RoomListServiceState.ERROR -> SyncState.InError
RoomListServiceState.TERMINATED -> SyncState.Terminated
}
}

View File

@@ -18,20 +18,18 @@ package io.element.android.libraries.matrix.impl.sync
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.room.roomListStateFlow
import io.element.android.libraries.matrix.impl.room.stateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import org.matrix.rustcomponents.sdk.RoomList
import org.matrix.rustcomponents.sdk.RoomListState
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomListServiceState
class RustSyncService(
private val roomListService: RoomList,
private val roomListService: RoomListService,
private val sessionCoroutineScope: CoroutineScope
) : SyncService {
@@ -49,8 +47,8 @@ class RustSyncService(
override val syncState: StateFlow<SyncState> =
roomListService
.roomListStateFlow()
.map(RoomListState::toSyncState)
.stateFlow()
.map(RoomListServiceState::toSyncState)
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.WhileSubscribed(), SyncState.Idle)
}

View File

@@ -70,6 +70,7 @@ fun RustEventSendState?.map(): EventSendState? {
RustEventSendState.NotSentYet -> EventSendState.NotSentYet
is RustEventSendState.SendingFailed -> EventSendState.SendingFailed(error)
is RustEventSendState.Sent -> EventSendState.Sent(EventId(eventId))
RustEventSendState.Cancelled -> EventSendState.Canceled
}
}

View File

@@ -75,6 +75,8 @@ class FakeMatrixRoom(
private var sendReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit)
var sendMediaCount = 0
private set
@@ -88,6 +90,9 @@ class FakeMatrixRoom(
var cancelSendCount: Int = 0
private set
var reportedContentCount: Int = 0
private set
var isInviteAccepted: Boolean = false
private set
@@ -218,6 +223,10 @@ class FakeMatrixRoom(
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia()
override suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventResult
}
private suspend fun fakeSendMedia(): Result<Unit> = simulateLongTask {
sendMediaResult.onSuccess {
sendMediaCount++
@@ -244,6 +253,15 @@ class FakeMatrixRoom(
setTopicResult
}
override suspend fun reportContent(
eventId: EventId,
reason: String,
blockUserId: UserId?
): Result<Unit> = simulateLongTask {
reportedContentCount++
return reportContentResult
}
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@@ -329,4 +347,12 @@ class FakeMatrixRoom(
fun givenCancelSendResult(result: Result<Unit>) {
cancelSendResult = result
}
fun givenForwardEventResult(result: Result<Unit>) {
forwardEventResult = result
}
fun givenReportContentResult(result: Result<Unit>) {
reportContentResult = result
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun SelectedRoom(
roomSummary: RoomSummaryDetails,
modifier: Modifier = Modifier,
onRoomRemoved: (RoomSummaryDetails) -> Unit = {},
) {
Box(modifier = modifier
.width(56.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.Custom(56.dp)))
Text(
text = roomSummary.name,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onRoomRemoved(roomSummary) }
),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
}
}
}
@Preview
@Composable
internal fun SelectedRoomLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SelectedRoom(roomSummary =
RoomSummaryDetails(
roomId = RoomId("!room:domain"),
name = "roomName",
canonicalAlias = null,
isDirect = true,
avatarURLString = null,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
inviter = null,
)
)
}

View File

@@ -28,6 +28,7 @@
<string name="action_invite">"Invite"</string>
<string name="action_invite_friends">"Invite friends"</string>
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
<string name="action_invite_people_to_app">"Invite people to %1$s"</string>
<string name="action_invites_list">"Invites"</string>
<string name="action_learn_more">"Learn more"</string>
<string name="action_leave">"Leave"</string>
@@ -108,6 +109,7 @@
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
<string name="common_shared_location">"Shared location"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>

View File

@@ -75,6 +75,8 @@ private fun DependencyHandlerScope.addImplementationProjects(
}
fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:androidutils"))
implementation(project(":libraries:deeplink"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:matrix:impl"))
implementation(project(":libraries:matrixui"))

View File

@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@SingleIn(AppScope::class)
@@ -45,7 +46,7 @@ class DefaultAnalyticsService @Inject constructor(
private val sessionObserver: SessionObserver,
) : AnalyticsService, SessionListener {
// Cache for the store values
private var userConsent: Boolean? = null
private val userConsent = AtomicBoolean(false)
// Cache for the properties to send
private var pendingUserProperties: UserProperties? = null
@@ -104,7 +105,7 @@ class DefaultAnalyticsService @Inject constructor(
getUserConsent()
.onEach { consent ->
Timber.tag(analyticsTag.value).d("User consent updated to $consent")
userConsent = consent
userConsent.set(consent)
initOrStop()
}
.launchIn(coroutineScope)
@@ -115,35 +116,33 @@ class DefaultAnalyticsService @Inject constructor(
}
private fun initOrStop() {
userConsent?.let { _userConsent ->
when (_userConsent) {
true -> {
pendingUserProperties?.let {
analyticsProviders.onEach { provider -> provider.updateUserProperties(it) }
pendingUserProperties = null
}
}
false -> {}
if (userConsent.get()) {
analyticsProviders.onEach { it.init() }
pendingUserProperties?.let {
analyticsProviders.onEach { provider -> provider.updateUserProperties(it) }
pendingUserProperties = null
}
} else {
analyticsProviders.onEach { it.stop() }
}
}
override fun capture(event: VectorAnalyticsEvent) {
Timber.tag(analyticsTag.value).d("capture($event)")
if (userConsent == true) {
if (userConsent.get()) {
analyticsProviders.onEach { it.capture(event) }
}
}
override fun screen(screen: VectorAnalyticsScreen) {
Timber.tag(analyticsTag.value).d("screen($screen)")
if (userConsent == true) {
if (userConsent.get()) {
analyticsProviders.onEach { it.screen(screen) }
}
}
override fun updateUserProperties(userProperties: UserProperties) {
if (userConsent == true) {
if (userConsent.get()) {
analyticsProviders.onEach { it.updateUserProperties(userProperties) }
} else {
pendingUserProperties = userProperties
@@ -151,7 +150,7 @@ class DefaultAnalyticsService @Inject constructor(
}
override fun trackError(throwable: Throwable) {
if (userConsent == true) {
if (userConsent.get()) {
analyticsProviders.onEach { it.trackError(throwable) }
}
}

View File

@@ -30,7 +30,7 @@ interface AnalyticsProvider: AnalyticsTracker, ErrorTracker {
*/
val name: String
suspend fun init()
fun init()
fun stop()
}

View File

@@ -42,7 +42,7 @@ class PosthogAnalyticsProvider @Inject constructor(
private var posthog: PostHog? = null
private var analyticsId: String? = null
override suspend fun init() {
override fun init() {
posthog = createPosthog()
posthog?.optOut(false)
identifyPostHog()
@@ -66,10 +66,10 @@ class PosthogAnalyticsProvider @Inject constructor(
}
override fun updateUserProperties(userProperties: UserProperties) {
posthog?.identify(
REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(),
IGNORED_OPTIONS
)
// posthog?.identify(
// REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(),
// IGNORED_OPTIONS
// )
}
override fun trackError(throwable: Throwable) {

View File

@@ -31,6 +31,7 @@ android {
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.parameter.injector)
testImplementation(projects.libraries.designsystem)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
kspTest(libs.showkase.processor)

View File

@@ -39,6 +39,8 @@ import com.android.ide.common.rendering.api.SessionParams
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -96,6 +98,8 @@ class ScreenshotTest {
LocalConfiguration provides Configuration().apply {
setLocales(LocaleList(localeStr.toLocale()))
},
// Needed to display Snackbars and avoid crashes during screenshot tests
LocalSnackbarDispatcher provides SnackbarDispatcher(),
// Needed so that UI that uses it don't crash during screenshot tests
LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner {
override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle

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