Merge branch 'develop' into feature/fga/room_list_api
This commit is contained in:
@@ -198,7 +198,6 @@ dependencies {
|
||||
allLibrariesImpl()
|
||||
allServicesImpl()
|
||||
allFeaturesImpl(rootDir, logger)
|
||||
implementation(projects.libraries.deeplink)
|
||||
implementation(projects.tests.uitests)
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.appnav)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
1
changelog.d/486.feature
Normal file
@@ -0,0 +1 @@
|
||||
Allow forawrding messages from one room to another
|
||||
1
changelog.d/489.feature
Normal file
1
changelog.d/489.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add option to report inappropriate content
|
||||
1
changelog.d/627.feature
Normal file
1
changelog.d/627.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add analytics events for room creation
|
||||
1
changelog.d/663.feature
Normal file
1
changelog.d/663.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add 'Copy' action to timeline item context menu, for text events
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ interface AnalyticsProvider: AnalyticsTracker, ErrorTracker {
|
||||
*/
|
||||
val name: String
|
||||
|
||||
suspend fun init()
|
||||
fun init()
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user