Invite users to existing rooms (#441)
Invite users to existing rooms Scope: - Allow inviting from the room detail screen and the member list - Invite option is only shown if the user has the correct power level - Search flow the same as creating a new room, allowing multi-select - Existing room members/invitees are disabled with a custom caption - Sending is asynchronous, an error dialog will appear wherever the user is if necessary Closes #245
This commit is contained in:
parent
882a155f07
commit
463b9e0642
85 changed files with 1668 additions and 69 deletions
|
|
@ -56,6 +56,7 @@ dependencies {
|
||||||
implementation(projects.tests.uitests)
|
implementation(projects.tests.uitests)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
implementation(projects.services.apperror.impl)
|
||||||
implementation(projects.services.appnavstate.api)
|
implementation(projects.services.appnavstate.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,30 @@
|
||||||
package io.element.android.appnav.root
|
package io.element.android.appnav.root
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
||||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
|
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.services.apperror.api.AppErrorStateService
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RootPresenter @Inject constructor(
|
class RootPresenter @Inject constructor(
|
||||||
private val crashDetectionPresenter: CrashDetectionPresenter,
|
private val crashDetectionPresenter: CrashDetectionPresenter,
|
||||||
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
||||||
|
private val appErrorStateService: AppErrorStateService,
|
||||||
) : Presenter<RootState> {
|
) : Presenter<RootState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RootState {
|
override fun present(): RootState {
|
||||||
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
|
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
|
||||||
val crashDetectionState = crashDetectionPresenter.present()
|
val crashDetectionState = crashDetectionPresenter.present()
|
||||||
|
val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState()
|
||||||
|
|
||||||
return RootState(
|
return RootState(
|
||||||
rageshakeDetectionState = rageshakeDetectionState,
|
rageshakeDetectionState = rageshakeDetectionState,
|
||||||
crashDetectionState = crashDetectionState,
|
crashDetectionState = crashDetectionState,
|
||||||
|
errorState = appErrorState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ package io.element.android.appnav.root
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.rageshake.api.crash.CrashDetectionState
|
import io.element.android.features.rageshake.api.crash.CrashDetectionState
|
||||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
|
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
|
||||||
|
import io.element.android.services.apperror.api.AppErrorState
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class RootState(
|
data class RootState(
|
||||||
val rageshakeDetectionState: RageshakeDetectionState,
|
val rageshakeDetectionState: RageshakeDetectionState,
|
||||||
val crashDetectionState: CrashDetectionState,
|
val crashDetectionState: CrashDetectionState,
|
||||||
|
val errorState: AppErrorState,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ package io.element.android.appnav.root
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.rageshake.api.crash.aCrashDetectionState
|
import io.element.android.features.rageshake.api.crash.aCrashDetectionState
|
||||||
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
|
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
|
||||||
|
import io.element.android.services.apperror.api.AppErrorState
|
||||||
|
import io.element.android.services.apperror.api.aAppErrorState
|
||||||
|
|
||||||
open class RootStateProvider : PreviewParameterProvider<RootState> {
|
open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||||
override val values: Sequence<RootState>
|
override val values: Sequence<RootState>
|
||||||
|
|
@ -30,6 +32,9 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||||
aRootState().copy(
|
aRootState().copy(
|
||||||
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
|
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
|
||||||
crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
|
crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
|
||||||
|
),
|
||||||
|
aRootState().copy(
|
||||||
|
errorState = aAppErrorState(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -37,4 +42,5 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||||
fun aRootState() = RootState(
|
fun aRootState() = RootState(
|
||||||
rageshakeDetectionState = aRageshakeDetectionState(),
|
rageshakeDetectionState = aRageshakeDetectionState(),
|
||||||
crashDetectionState = aCrashDetectionState(),
|
crashDetectionState = aCrashDetectionState(),
|
||||||
|
errorState = AppErrorState.NoError,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionVie
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.services.apperror.impl.AppErrorView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootView(
|
fun RootView(
|
||||||
|
|
@ -60,6 +61,9 @@ fun RootView(
|
||||||
state = state.crashDetectionState,
|
state = state.crashDetectionState,
|
||||||
onOpenBugReport = ::onOpenBugReport,
|
onOpenBugReport = ::onOpenBugReport,
|
||||||
)
|
)
|
||||||
|
AppErrorView(
|
||||||
|
state = state.errorState,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
||||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||||
|
import io.element.android.services.apperror.api.AppErrorState
|
||||||
|
import io.element.android.services.apperror.api.AppErrorStateService
|
||||||
|
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -44,7 +47,32 @@ class RootPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(): RootPresenter {
|
@Test
|
||||||
|
fun `present - passes app error state`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
appErrorService = DefaultAppErrorStateService().apply {
|
||||||
|
showError("Bad news", "Something bad happened")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java)
|
||||||
|
val initialErrorState = initialState.errorState as AppErrorState.Error
|
||||||
|
assertThat(initialErrorState.title).isEqualTo("Bad news")
|
||||||
|
assertThat(initialErrorState.body).isEqualTo("Something bad happened")
|
||||||
|
|
||||||
|
initialErrorState.dismiss()
|
||||||
|
assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
|
||||||
|
): RootPresenter {
|
||||||
val crashDataStore = FakeCrashDataStore()
|
val crashDataStore = FakeCrashDataStore()
|
||||||
val rageshakeDataStore = FakeRageshakeDataStore()
|
val rageshakeDataStore = FakeRageshakeDataStore()
|
||||||
val rageshake = FakeRageShake()
|
val rageshake = FakeRageShake()
|
||||||
|
|
@ -63,6 +91,7 @@ class RootPresenterTest {
|
||||||
return RootPresenter(
|
return RootPresenter(
|
||||||
crashDetectionPresenter = crashDetectionPresenter,
|
crashDetectionPresenter = crashDetectionPresenter,
|
||||||
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
|
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
|
||||||
|
appErrorStateService = appErrorService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/245.feature
Normal file
1
changelog.d/245.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] Add ability to invite users to existing rooms
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.createroom.impl.R
|
import io.element.android.features.createroom.impl.R
|
||||||
import io.element.android.features.createroom.impl.components.UserListView
|
import io.element.android.features.createroom.impl.components.UserListView
|
||||||
|
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -54,13 +55,17 @@ fun AddPeopleView(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!state.isSearchActive) {
|
AddPeopleViewTopBar(
|
||||||
AddPeopleViewTopBar(
|
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
|
||||||
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
|
onBackPressed = {
|
||||||
onBackPressed = onBackPressed,
|
if (state.isSearchActive) {
|
||||||
onNextPressed = onNextPressed,
|
state.eventSink(UserListEvents.OnSearchActiveChanged(false))
|
||||||
)
|
} else {
|
||||||
}
|
onBackPressed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNextPressed = onNextPressed,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -73,6 +78,7 @@ fun AddPeopleView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
state = state,
|
state = state,
|
||||||
|
showBackButton = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ fun SearchUserBar(
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isMultiSelectionEnabled: Boolean,
|
isMultiSelectionEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
|
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
|
||||||
onActiveChanged: (Boolean) -> Unit = {},
|
onActiveChanged: (Boolean) -> Unit = {},
|
||||||
onTextChanged: (String) -> Unit = {},
|
onTextChanged: (String) -> Unit = {},
|
||||||
|
|
@ -52,6 +53,7 @@ fun SearchUserBar(
|
||||||
onActiveChange = onActiveChanged,
|
onActiveChange = onActiveChanged,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
placeHolderTitle = placeHolderTitle,
|
placeHolderTitle = placeHolderTitle,
|
||||||
|
showBackButton = showBackButton,
|
||||||
contentPrefix = {
|
contentPrefix = {
|
||||||
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
|
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
|
||||||
SelectedUsersList(
|
SelectedUsersList(
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||||
fun UserListView(
|
fun UserListView(
|
||||||
state: UserListState,
|
state: UserListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
onUserSelected: (MatrixUser) -> Unit = {},
|
onUserSelected: (MatrixUser) -> Unit = {},
|
||||||
onUserDeselected: (MatrixUser) -> Unit = {},
|
onUserDeselected: (MatrixUser) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -49,6 +50,7 @@ fun UserListView(
|
||||||
selectedUsers = state.selectedUsers,
|
selectedUsers = state.selectedUsers,
|
||||||
active = state.isSearchActive,
|
active = state.isSearchActive,
|
||||||
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
||||||
|
showBackButton = showBackButton,
|
||||||
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
|
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
|
||||||
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
|
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
|
||||||
onUserSelected = {
|
onUserSelected = {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
api(projects.features.roomdetails.api)
|
api(projects.features.roomdetails.api)
|
||||||
api(projects.libraries.usersearch.api)
|
api(projects.libraries.usersearch.api)
|
||||||
|
api(projects.services.apperror.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
|
@ -51,6 +52,7 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
||||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
|
|
@ -57,6 +58,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RoomMemberList : NavTarget
|
object RoomMemberList : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object InviteMembers : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
|
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +72,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
override fun openRoomMemberList() {
|
override fun openRoomMemberList() {
|
||||||
backstack.push(NavTarget.RoomMemberList)
|
backstack.push(NavTarget.RoomMemberList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun openInviteMembers() {
|
||||||
|
backstack.push(NavTarget.InviteMembers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +84,16 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
override fun openRoomMemberDetails(roomMemberId: UserId) {
|
override fun openRoomMemberDetails(roomMemberId: UserId) {
|
||||||
backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
|
backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun openInviteMembers() {
|
||||||
|
backstack.push(NavTarget.InviteMembers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
|
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
|
||||||
}
|
}
|
||||||
|
NavTarget.InviteMembers -> {
|
||||||
|
createNode<RoomInviteMembersNode>(buildContext)
|
||||||
|
}
|
||||||
is NavTarget.RoomMemberDetails -> {
|
is NavTarget.RoomMemberDetails -> {
|
||||||
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
|
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun openRoomMemberList()
|
fun openRoomMemberList()
|
||||||
|
fun openInviteMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callbacks = plugins<Callback>()
|
private val callbacks = plugins<Callback>()
|
||||||
|
|
@ -53,6 +54,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
callbacks.forEach { it.openRoomMemberList() }
|
callbacks.forEach { it.openRoomMemberList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun invitePeople() {
|
||||||
|
callbacks.forEach { it.openInviteMembers() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun onShareRoom(context: Context) {
|
private fun onShareRoom(context: Context) {
|
||||||
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
|
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
|
||||||
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
|
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
|
||||||
|
|
@ -105,6 +110,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
onShareRoom = ::onShareRoom,
|
onShareRoom = ::onShareRoom,
|
||||||
onShareMember = ::onShareMember,
|
onShareMember = ::onShareMember,
|
||||||
openRoomMemberList = ::openRoomMemberList,
|
openRoomMemberList = ::openRoomMemberList,
|
||||||
|
invitePeople = ::invitePeople,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
|
|
||||||
val membersState by room.membersStateFlow.collectAsState()
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
val memberCount by getMemberCount(membersState)
|
val memberCount by getMemberCount(membersState)
|
||||||
|
val canInvite by getCanInvite(membersState)
|
||||||
val dmMember by room.getDirectRoomMember(membersState)
|
val dmMember by room.getDirectRoomMember(membersState)
|
||||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||||
val roomType = getRoomType(dmMember)
|
val roomType = getRoomType(dmMember)
|
||||||
|
|
@ -76,7 +77,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
error = error,
|
error = error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
|
RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
|
||||||
RoomDetailsEvent.ClearError -> error.value = null
|
RoomDetailsEvent.ClearError -> error.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +92,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
roomTopic = room.topic,
|
roomTopic = room.topic,
|
||||||
memberCount = memberCount,
|
memberCount = memberCount,
|
||||||
isEncrypted = room.isEncrypted,
|
isEncrypted = room.isEncrypted,
|
||||||
|
canInvite = canInvite,
|
||||||
displayLeaveRoomWarning = leaveRoomWarning.value,
|
displayLeaveRoomWarning = leaveRoomWarning.value,
|
||||||
error = error.value,
|
error = error.value,
|
||||||
roomType = roomType.value,
|
roomType = roomType.value,
|
||||||
|
|
@ -117,6 +119,15 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
|
||||||
|
val canInvite = remember(membersState) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(membersState) {
|
||||||
|
canInvite.value = room.canInvite().getOrElse { false }
|
||||||
|
}
|
||||||
|
return canInvite
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
|
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
|
||||||
return remember(membersState) {
|
return remember(membersState) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ data class RoomDetailsState(
|
||||||
val error: RoomDetailsError?,
|
val error: RoomDetailsError?,
|
||||||
val roomType: RoomDetailsType,
|
val roomType: RoomDetailsType,
|
||||||
val roomMemberDetailsState: RoomMemberDetailsState?,
|
val roomMemberDetailsState: RoomMemberDetailsState?,
|
||||||
|
val canInvite: Boolean,
|
||||||
val eventSink: (RoomDetailsEvent) -> Unit
|
val eventSink: (RoomDetailsEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
||||||
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
||||||
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||||
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||||
|
aRoomDetailsState().copy(canInvite = true),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +72,7 @@ fun aRoomDetailsState() = RoomDetailsState(
|
||||||
isEncrypted = true,
|
isEncrypted = true,
|
||||||
displayLeaveRoomWarning = null,
|
displayLeaveRoomWarning = null,
|
||||||
error = null,
|
error = null,
|
||||||
|
canInvite = false,
|
||||||
roomType = RoomDetailsType.Room,
|
roomType = RoomDetailsType.Room,
|
||||||
roomMemberDetailsState = null,
|
roomMemberDetailsState = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ fun RoomDetailsView(
|
||||||
onShareRoom: () -> Unit,
|
onShareRoom: () -> Unit,
|
||||||
onShareMember: (RoomMember) -> Unit,
|
onShareMember: (RoomMember) -> Unit,
|
||||||
openRoomMemberList: () -> Unit,
|
openRoomMemberList: () -> Unit,
|
||||||
|
invitePeople: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -127,7 +128,9 @@ fun RoomDetailsView(
|
||||||
MembersSection(
|
MembersSection(
|
||||||
memberCount = memberCount,
|
memberCount = memberCount,
|
||||||
isLoading = state.memberCount.isLoading(),
|
isLoading = state.memberCount.isLoading(),
|
||||||
openRoomMemberList = openRoomMemberList
|
showInvite = state.canInvite,
|
||||||
|
openRoomMemberList = openRoomMemberList,
|
||||||
|
invitePeople = invitePeople,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,8 +214,10 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
||||||
internal fun MembersSection(
|
internal fun MembersSection(
|
||||||
memberCount: Int?,
|
memberCount: Int?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
showInvite: Boolean,
|
||||||
|
invitePeople: () -> Unit,
|
||||||
|
openRoomMemberList: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
openRoomMemberList: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
PreferenceCategory(modifier = modifier) {
|
PreferenceCategory(modifier = modifier) {
|
||||||
PreferenceText(
|
PreferenceText(
|
||||||
|
|
@ -222,10 +227,13 @@ internal fun MembersSection(
|
||||||
onClick = openRoomMemberList,
|
onClick = openRoomMemberList,
|
||||||
loadingCurrentValue = isLoading,
|
loadingCurrentValue = isLoading,
|
||||||
)
|
)
|
||||||
PreferenceText(
|
if (showInvite) {
|
||||||
title = stringResource(R.string.screen_room_details_invite_people_title),
|
PreferenceText(
|
||||||
icon = Icons.Outlined.PersonAddAlt,
|
title = stringResource(R.string.screen_room_details_invite_people_title),
|
||||||
)
|
icon = Icons.Outlined.PersonAddAlt,
|
||||||
|
onClick = invitePeople,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,5 +299,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||||
onShareRoom = {},
|
onShareRoom = {},
|
||||||
onShareMember = {},
|
onShareMember = {},
|
||||||
openRoomMemberList = {},
|
openRoomMemberList = {},
|
||||||
|
invitePeople = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.features.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
|
sealed interface RoomInviteMembersEvents {
|
||||||
|
data class ToggleUser(val user: MatrixUser) : RoomInviteMembersEvents
|
||||||
|
data class UpdateSearchQuery(val query: String) : RoomInviteMembersEvents
|
||||||
|
data class OnSearchActiveChanged(val active: Boolean) : RoomInviteMembersEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.services.apperror.api.AppErrorStateService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class RoomInviteMembersNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val presenter: RoomInviteMembersPresenter,
|
||||||
|
private val appErrorStateService: AppErrorStateService,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
RoomInviteMembersView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
onBackPressed = { navigateUp() },
|
||||||
|
onSendPressed = { users ->
|
||||||
|
navigateUp()
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
val anyInviteFailed = users
|
||||||
|
.map { room.inviteUserById(it.userId) }
|
||||||
|
.any { it.isFailure }
|
||||||
|
|
||||||
|
if (anyInviteFailed) {
|
||||||
|
appErrorStateService.showError(
|
||||||
|
title = context.getString(StringR.string.common_unable_to_invite_title),
|
||||||
|
body = context.getString(StringR.string.common_unable_to_invite_message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
room.updateMembers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||||
|
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.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.usersearch.api.UserRepository
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RoomInviteMembersPresenter @Inject constructor(
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
) : Presenter<RoomInviteMembersState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): RoomInviteMembersState {
|
||||||
|
val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) }
|
||||||
|
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
|
||||||
|
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.NotSearching()) }
|
||||||
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
fetchMembers(roomMembers)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(searchQuery, roomMembers) {
|
||||||
|
performSearch(searchResults, roomMembers, selectedUsers, searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoomInviteMembersState(
|
||||||
|
canInvite = selectedUsers.value.isNotEmpty(),
|
||||||
|
selectedUsers = selectedUsers.value,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isSearchActive = searchActive,
|
||||||
|
searchResults = searchResults.value,
|
||||||
|
eventSink = {
|
||||||
|
when (it) {
|
||||||
|
is RoomInviteMembersEvents.OnSearchActiveChanged -> {
|
||||||
|
searchActive = it.active
|
||||||
|
searchQuery = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
is RoomInviteMembersEvents.UpdateSearchQuery -> {
|
||||||
|
searchQuery = it.query
|
||||||
|
}
|
||||||
|
|
||||||
|
is RoomInviteMembersEvents.ToggleUser -> {
|
||||||
|
selectedUsers.toggleUser(it.user)
|
||||||
|
searchResults.toggleUser(it.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("toggleUserInSelectedUsers")
|
||||||
|
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
|
||||||
|
value = if (value.contains(user)) {
|
||||||
|
value.filterNot { it == user }
|
||||||
|
} else {
|
||||||
|
(value + user)
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("toggleUserInSearchResults")
|
||||||
|
private fun MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>.toggleUser(user: MatrixUser) {
|
||||||
|
val existingResults = value
|
||||||
|
if (existingResults is SearchBarResultState.Results) {
|
||||||
|
value = SearchBarResultState.Results(
|
||||||
|
existingResults.results.map { iu ->
|
||||||
|
if (iu.matrixUser == user) {
|
||||||
|
iu.copy(isSelected = !iu.isSelected)
|
||||||
|
} else {
|
||||||
|
iu
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performSearch(
|
||||||
|
searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>,
|
||||||
|
roomMembers: MutableState<Async<ImmutableList<RoomMember>>>,
|
||||||
|
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
|
||||||
|
searchQuery: String,
|
||||||
|
) = withContext(coroutineDispatchers.io) {
|
||||||
|
searchResults.value = SearchBarResultState.NotSearching()
|
||||||
|
|
||||||
|
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
|
||||||
|
|
||||||
|
userRepository.search(searchQuery).collect {
|
||||||
|
searchResults.value = when {
|
||||||
|
it.isEmpty() -> SearchBarResultState.NoResults()
|
||||||
|
else -> SearchBarResultState.Results(it.map { user ->
|
||||||
|
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership
|
||||||
|
val isJoined = existingMembership == RoomMembershipState.JOIN
|
||||||
|
val isInvited = existingMembership == RoomMembershipState.INVITE
|
||||||
|
InvitableUser(
|
||||||
|
matrixUser = user,
|
||||||
|
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited,
|
||||||
|
isAlreadyJoined = isJoined,
|
||||||
|
isAlreadyInvited = isInvited,
|
||||||
|
)
|
||||||
|
}.toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) {
|
||||||
|
suspend {
|
||||||
|
withContext(coroutineDispatchers.io) {
|
||||||
|
roomMemberListDataSource.search("").toImmutableList()
|
||||||
|
}
|
||||||
|
}.execute(roomMembers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
data class RoomInviteMembersState(
|
||||||
|
val canInvite: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.NotSearching(),
|
||||||
|
val selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||||
|
val isSearchActive: Boolean = false,
|
||||||
|
val eventSink: (RoomInviteMembersEvents) -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InvitableUser(
|
||||||
|
val matrixUser: MatrixUser,
|
||||||
|
val isSelected: Boolean = false,
|
||||||
|
val isAlreadyJoined: Boolean = false,
|
||||||
|
val isAlreadyInvited: Boolean = false,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInviteMembersState> {
|
||||||
|
override val values: Sequence<RoomInviteMembersState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
RoomInviteMembersState(),
|
||||||
|
RoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
|
||||||
|
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query"),
|
||||||
|
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
|
||||||
|
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResults()),
|
||||||
|
RoomInviteMembersState(
|
||||||
|
isSearchActive = true,
|
||||||
|
canInvite = true,
|
||||||
|
searchQuery = "some query",
|
||||||
|
selectedUsers = persistentListOf(
|
||||||
|
aMatrixUser("@carol:server.org", "Carol")
|
||||||
|
),
|
||||||
|
searchResults = SearchBarResultState.Results(
|
||||||
|
persistentListOf(
|
||||||
|
InvitableUser(aMatrixUser("@alice:server.org")),
|
||||||
|
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
|
||||||
|
InvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true),
|
||||||
|
InvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true),
|
||||||
|
InvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.features.roomdetails.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
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.CenterAlignedTopAppBar
|
||||||
|
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.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||||
|
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun RoomInviteMembersView(
|
||||||
|
state: RoomInviteMembersState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
onSendPressed: (List<MatrixUser>) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
RoomInviteMembersTopBar(
|
||||||
|
onBackPressed = {
|
||||||
|
if (state.isSearchActive) {
|
||||||
|
state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false))
|
||||||
|
} else {
|
||||||
|
onBackPressed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSendPressed = { onSendPressed(state.selectedUsers) },
|
||||||
|
canSend = state.canInvite,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(padding)
|
||||||
|
.consumeWindowInsets(padding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
RoomInviteMembersSearchBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
query = state.searchQuery,
|
||||||
|
selectedUsers = state.selectedUsers,
|
||||||
|
state = state.searchResults,
|
||||||
|
active = state.isSearchActive,
|
||||||
|
onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) },
|
||||||
|
onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) },
|
||||||
|
onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!state.isSearchActive) {
|
||||||
|
SelectedUsersList(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectedUsers = state.selectedUsers,
|
||||||
|
autoScroll = true,
|
||||||
|
onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RoomInviteMembersTopBar(
|
||||||
|
canSend: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
onSendPressed: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_room_details_invite_people_title),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||||
|
actions = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onSendPressed,
|
||||||
|
content = {
|
||||||
|
Text(stringResource(StringR.string.action_send))
|
||||||
|
},
|
||||||
|
enabled = canSend,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoomInviteMembersSearchBar(
|
||||||
|
query: String,
|
||||||
|
state: SearchBarResultState<ImmutableList<InvitableUser>>,
|
||||||
|
selectedUsers: ImmutableList<MatrixUser>,
|
||||||
|
active: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeHolderTitle: String = stringResource(io.element.android.libraries.ui.strings.R.string.common_search_for_someone),
|
||||||
|
onActiveChanged: (Boolean) -> Unit = {},
|
||||||
|
onTextChanged: (String) -> Unit = {},
|
||||||
|
onUserToggled: (MatrixUser) -> Unit = {},
|
||||||
|
) {
|
||||||
|
SearchBar(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onTextChanged,
|
||||||
|
active = active,
|
||||||
|
onActiveChange = onActiveChanged,
|
||||||
|
modifier = modifier,
|
||||||
|
placeHolderTitle = placeHolderTitle,
|
||||||
|
contentPrefix = {
|
||||||
|
if (selectedUsers.isNotEmpty()) {
|
||||||
|
SelectedUsersList(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectedUsers = selectedUsers,
|
||||||
|
autoScroll = true,
|
||||||
|
onUserRemoved = onUserToggled,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showBackButton = false,
|
||||||
|
resultState = state,
|
||||||
|
resultHandler = { results ->
|
||||||
|
Text(
|
||||||
|
text = "Search results",
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
items(results) { invitableUser ->
|
||||||
|
CheckableUserRow(
|
||||||
|
checked = invitableUser.isSelected,
|
||||||
|
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
|
||||||
|
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
|
||||||
|
name = invitableUser.matrixUser.getBestName(),
|
||||||
|
subtext = when {
|
||||||
|
// If they're already invited or joined we show that information
|
||||||
|
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
|
||||||
|
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
|
||||||
|
// Otherwise show the ID, unless that's already used for their name
|
||||||
|
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: RoomInviteMembersState) {
|
||||||
|
RoomInviteMembersView(state)
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,6 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class RoomMemberListNode @AssistedInject constructor(
|
class RoomMemberListNode @AssistedInject constructor(
|
||||||
|
|
@ -38,6 +37,7 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun openRoomMemberDetails(roomMemberId: UserId)
|
fun openRoomMemberDetails(roomMemberId: UserId)
|
||||||
|
fun openInviteMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callbacks = plugins<Callback>()
|
private val callbacks = plugins<Callback>()
|
||||||
|
|
@ -48,6 +48,12 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openInviteMembers() {
|
||||||
|
callbacks.forEach {
|
||||||
|
it.openInviteMembers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -56,6 +62,7 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = { navigateUp() },
|
onBackPressed = { navigateUp() },
|
||||||
onMemberSelected = this::openRoomMemberDetails,
|
onMemberSelected = this::openRoomMemberDetails,
|
||||||
|
onInvitePressed = this::openInviteMembers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -27,12 +29,15 @@ import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RoomMemberListPresenter @Inject constructor(
|
class RoomMemberListPresenter @Inject constructor(
|
||||||
|
private val room: MatrixRoom,
|
||||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : Presenter<RoomMemberListState> {
|
) : Presenter<RoomMemberListState> {
|
||||||
|
|
@ -46,6 +51,9 @@ class RoomMemberListPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
|
val canInvite by getCanInvite(membersState = membersState)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
val members = roomMemberListDataSource.search("").groupBy { it.membership }
|
val members = roomMemberListDataSource.search("").groupBy { it.membership }
|
||||||
|
|
@ -80,6 +88,7 @@ class RoomMemberListPresenter @Inject constructor(
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
searchResults = searchResults,
|
searchResults = searchResults,
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
|
canInvite = canInvite,
|
||||||
eventSink = { event ->
|
eventSink = { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||||
|
|
@ -88,5 +97,14 @@ class RoomMemberListPresenter @Inject constructor(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
|
||||||
|
val canInvite = remember(membersState) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(membersState) {
|
||||||
|
canInvite.value = room.canInvite().getOrElse { false }
|
||||||
|
}
|
||||||
|
return canInvite
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ data class RoomMemberListState(
|
||||||
val searchQuery: String,
|
val searchQuery: String,
|
||||||
val searchResults: SearchBarResultState<RoomMembers>,
|
val searchResults: SearchBarResultState<RoomMembers>,
|
||||||
val isSearchActive: Boolean,
|
val isSearchActive: Boolean,
|
||||||
|
val canInvite: Boolean,
|
||||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
aRoomMemberListState(roomMembers = Async.Loading()),
|
aRoomMemberListState(roomMembers = Async.Loading()),
|
||||||
|
aRoomMemberListState().copy(canInvite = true),
|
||||||
aRoomMemberListState().copy(isSearchActive = false),
|
aRoomMemberListState().copy(isSearchActive = false),
|
||||||
aRoomMemberListState().copy(isSearchActive = true),
|
aRoomMemberListState().copy(isSearchActive = true),
|
||||||
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||||
|
|
@ -65,6 +66,7 @@ internal fun aRoomMemberListState(
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
searchResults = searchResults,
|
searchResults = searchResults,
|
||||||
isSearchActive = false,
|
isSearchActive = false,
|
||||||
|
canInvite = false,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ 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.SearchBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
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.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -68,6 +69,7 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||||
fun RoomMemberListView(
|
fun RoomMemberListView(
|
||||||
state: RoomMemberListState,
|
state: RoomMemberListState,
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
|
onInvitePressed: () -> Unit,
|
||||||
onMemberSelected: (UserId) -> Unit,
|
onMemberSelected: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -79,7 +81,11 @@ fun RoomMemberListView(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!state.isSearchActive) {
|
if (!state.isSearchActive) {
|
||||||
RoomMemberListTopBar(onBackPressed = onBackPressed)
|
RoomMemberListTopBar(
|
||||||
|
canInvite = state.canInvite,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
onInvitePressed = onInvitePressed,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -192,8 +198,10 @@ private fun RoomMemberListItem(
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomMemberListTopBar(
|
private fun RoomMemberListTopBar(
|
||||||
|
canInvite: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackPressed: () -> Unit = {},
|
onBackPressed: () -> Unit = {},
|
||||||
|
onInvitePressed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -205,6 +213,19 @@ private fun RoomMemberListTopBar(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = { BackButton(onClick = onBackPressed) },
|
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||||
|
actions = {
|
||||||
|
if (canInvite) {
|
||||||
|
TextButton(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
onClick = onInvitePressed,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(StringR.string.action_invite),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,6 +273,7 @@ private fun ContentToPreview(state: RoomMemberListState) {
|
||||||
RoomMemberListView(
|
RoomMemberListView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = {},
|
onBackPressed = {},
|
||||||
onMemberSelected = {}
|
onMemberSelected = {},
|
||||||
|
onInvitePressed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -33,7 +32,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||||
|
|
@ -101,18 +99,19 @@ class RoomDetailsPresenterTests {
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
|
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
|
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
|
||||||
|
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
|
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
|
||||||
//skipItems(1)
|
skipItems(1)
|
||||||
val failureState = awaitItem()
|
val failureState = awaitItem()
|
||||||
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
|
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
|
||||||
|
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||||
//skipItems(1)
|
skipItems(1)
|
||||||
val successState = awaitItem()
|
val successState = awaitItem()
|
||||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||||
|
|
||||||
|
|
@ -166,6 +165,8 @@ class RoomDetailsPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||||
val confirmationState = awaitItem()
|
val confirmationState = awaitItem()
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
|
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
|
||||||
|
|
@ -182,6 +183,8 @@ class RoomDetailsPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||||
val confirmationState = awaitItem()
|
val confirmationState = awaitItem()
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
|
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
|
||||||
|
|
@ -198,6 +201,8 @@ class RoomDetailsPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||||
val confirmationState = awaitItem()
|
val confirmationState = awaitItem()
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
|
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
|
||||||
|
|
@ -214,6 +219,8 @@ class RoomDetailsPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
|
@ -235,6 +242,8 @@ class RoomDetailsPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
||||||
val errorState = awaitItem()
|
val errorState = awaitItem()
|
||||||
Truth.assertThat(errorState.error).isNotNull()
|
Truth.assertThat(errorState.error).isNotNull()
|
||||||
|
|
@ -242,6 +251,50 @@ class RoomDetailsPresenterTests {
|
||||||
Truth.assertThat(awaitItem().error).isNull()
|
Truth.assertThat(awaitItem().error).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state when user can invite others to room`() = runTest {
|
||||||
|
val room = aMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.success(true))
|
||||||
|
}
|
||||||
|
val presenter = aRoomDetailsPresenter(room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Initially false
|
||||||
|
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||||
|
// Then the asynchronous check completes and it becomes true
|
||||||
|
Truth.assertThat(awaitItem().canInvite).isTrue()
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state when user can not invite others to room`() = runTest {
|
||||||
|
val room = aMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.success(false))
|
||||||
|
}
|
||||||
|
val presenter = aRoomDetailsPresenter(room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state when canInvite errors`() = runTest {
|
||||||
|
val room = aMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.failure(Throwable("Whoops")))
|
||||||
|
}
|
||||||
|
val presenter = aRoomDetailsPresenter(room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMatrixClient(
|
fun aMatrixClient(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
/*
|
||||||
|
* 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.roomdetails.impl.invite
|
||||||
|
|
||||||
|
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.roomdetails.aMatrixRoom
|
||||||
|
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||||
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
|
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
internal class RoomInviteMembersPresenterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state has no results and no search`() = runTest {
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = FakeUserRepository(),
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
|
||||||
|
assertThat(initialState.isSearchActive).isFalse()
|
||||||
|
assertThat(initialState.canInvite).isFalse()
|
||||||
|
assertThat(initialState.searchQuery).isEmpty()
|
||||||
|
|
||||||
|
skipItems(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - updates search active state`() = runTest {
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = FakeUserRepository(),
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(true))
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.isSearchActive).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - performs search and handles no results`() = runTest {
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
repository.emitResult(emptyList())
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - performs search and handles user results`() = runTest {
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
repository.emitResult(aMatrixUserList())
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
|
|
||||||
|
val expectedUsers = aMatrixUserList()
|
||||||
|
val users = resultState.searchResults.users()
|
||||||
|
expectedUsers.forEachIndexed { index, matrixUser ->
|
||||||
|
assertThat(users[index].matrixUser).isEqualTo(matrixUser)
|
||||||
|
assertThat(users[index].isAlreadyInvited).isFalse()
|
||||||
|
assertThat(users[index].isAlreadyJoined).isFalse()
|
||||||
|
assertThat(users[index].isSelected).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - performs search and handles membership state of existing users`() = runTest {
|
||||||
|
val userList = aMatrixUserList()
|
||||||
|
val joinedUser = userList[0]
|
||||||
|
val invitedUser = userList[1]
|
||||||
|
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
|
||||||
|
aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
|
||||||
|
aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
|
||||||
|
)))
|
||||||
|
}),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
repository.emitResult(aMatrixUserList())
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
|
|
||||||
|
val users = resultState.searchResults.users()
|
||||||
|
|
||||||
|
// The result that matches a user with JOINED membership is marked as such
|
||||||
|
val userWhoShouldBeJoined = users.find { it.matrixUser == joinedUser }
|
||||||
|
assertThat(userWhoShouldBeJoined).isNotNull()
|
||||||
|
assertThat(userWhoShouldBeJoined?.isAlreadyJoined).isTrue()
|
||||||
|
assertThat(userWhoShouldBeJoined?.isAlreadyInvited).isFalse()
|
||||||
|
|
||||||
|
// The result that matches a user with INVITED membership is marked as such
|
||||||
|
val userWhoShouldBeInvited = users.find { it.matrixUser == invitedUser }
|
||||||
|
assertThat(userWhoShouldBeInvited).isNotNull()
|
||||||
|
assertThat(userWhoShouldBeInvited?.isAlreadyJoined).isFalse()
|
||||||
|
assertThat(userWhoShouldBeInvited?.isAlreadyInvited).isTrue()
|
||||||
|
|
||||||
|
// All other users are neither joined nor invited
|
||||||
|
val otherUsers = users.minus(userWhoShouldBeInvited!!).minus(userWhoShouldBeJoined!!)
|
||||||
|
assertThat(otherUsers.none { it.isAlreadyInvited }).isTrue()
|
||||||
|
assertThat(otherUsers.none { it.isAlreadyJoined }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - toggle users updates selected user state`() = runTest {
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
// When we toggle a user not in the list, they are added
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
|
||||||
|
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser())
|
||||||
|
|
||||||
|
// Toggling a different user also adds them
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value)))
|
||||||
|
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(), aMatrixUser(id = A_USER_ID_2.value))
|
||||||
|
|
||||||
|
// Toggling the first user removes them
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
|
||||||
|
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - selected users appear as such in search results`() = runTest {
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val selectedUser = aMatrixUser()
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
repository.emitResult(aMatrixUserList() + selectedUser)
|
||||||
|
skipItems(2)
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
|
|
||||||
|
val users = resultState.searchResults.users()
|
||||||
|
|
||||||
|
// The one user we have previously toggled is marked as selected
|
||||||
|
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
|
||||||
|
assertThat(shouldBeSelectedUser).isNotNull()
|
||||||
|
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
|
||||||
|
|
||||||
|
// And no others are
|
||||||
|
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
|
||||||
|
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - toggling a user updates existing search results`() = runTest {
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val selectedUser = aMatrixUser()
|
||||||
|
|
||||||
|
// Given a query is made
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
repository.emitResult(aMatrixUserList() + selectedUser)
|
||||||
|
skipItems(2)
|
||||||
|
|
||||||
|
// And then a user is toggled
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
|
||||||
|
skipItems(1)
|
||||||
|
val resultState = awaitItem()
|
||||||
|
|
||||||
|
// The results are updated...
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
|
val users = resultState.searchResults.users()
|
||||||
|
|
||||||
|
// The one user we have now toggled is marked as selected
|
||||||
|
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
|
||||||
|
assertThat(shouldBeSelectedUser).isNotNull()
|
||||||
|
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
|
||||||
|
|
||||||
|
// And no others are
|
||||||
|
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
|
||||||
|
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDataSource(
|
||||||
|
matrixRoom: MatrixRoom = aMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||||
|
},
|
||||||
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
|
||||||
|
|
||||||
|
private fun SearchBarResultState<ImmutableList<InvitableUser>>.users() =
|
||||||
|
(this as? SearchBarResultState.Results<ImmutableList<InvitableUser>>)?.results.orEmpty()
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -113,6 +114,54 @@ class RoomMemberListPresenterTests {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
matrixRoom = FakeMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.success(true))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
Truth.assertThat(loadedState.canInvite).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
matrixRoom = FakeMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.success(false))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
Truth.assertThat(loadedState.canInvite).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
matrixRoom = FakeMatrixRoom().apply {
|
||||||
|
givenCanInviteResult(Result.failure(Throwable("Eek")))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
Truth.assertThat(loadedState.canInvite).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
|
|
@ -125,6 +174,7 @@ private fun createDataSource(
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
|
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
|
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||||
) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers)
|
) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ fun <T> SearchBar(
|
||||||
placeHolderTitle: String,
|
placeHolderTitle: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
|
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
|
||||||
shape: Shape = SearchBarDefaults.inputFieldShape,
|
shape: Shape = SearchBarDefaults.inputFieldShape,
|
||||||
tonalElevation: Dp = SearchBarDefaults.Elevation,
|
tonalElevation: Dp = SearchBarDefaults.Elevation,
|
||||||
|
|
@ -87,7 +88,7 @@ fun <T> SearchBar(
|
||||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingIcon = if (active) {
|
leadingIcon = if (showBackButton && active) {
|
||||||
{ BackButton(onClick = { onActiveChange(false) }) }
|
{ BackButton(onClick = { onActiveChange(false) }) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -179,6 +180,16 @@ internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(group = PreviewGroup.Search)
|
||||||
|
@Composable
|
||||||
|
internal fun SearchBarPreviewActiveWithQueryNoBackButton() = ElementThemedPreview {
|
||||||
|
ContentToPreview(
|
||||||
|
query = "search term",
|
||||||
|
active = true,
|
||||||
|
showBackButton = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(group = PreviewGroup.Search)
|
@Preview(group = PreviewGroup.Search)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
|
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
|
||||||
|
|
@ -212,6 +223,7 @@ internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
|
||||||
private fun ContentToPreview(
|
private fun ContentToPreview(
|
||||||
query: String = "",
|
query: String = "",
|
||||||
active: Boolean = false,
|
active: Boolean = false,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
|
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
|
||||||
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
||||||
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
||||||
|
|
@ -221,6 +233,7 @@ private fun ContentToPreview(
|
||||||
query = query,
|
query = query,
|
||||||
active = active,
|
active = active,
|
||||||
resultState = resultState,
|
resultState = resultState,
|
||||||
|
showBackButton = showBackButton,
|
||||||
onQueryChange = {},
|
onQueryChange = {},
|
||||||
onActiveChange = {},
|
onActiveChange = {},
|
||||||
placeHolderTitle = "Search for things",
|
placeHolderTitle = "Search for things",
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,8 @@ interface MatrixRoom : Closeable {
|
||||||
suspend fun acceptInvitation(): Result<Unit>
|
suspend fun acceptInvitation(): Result<Unit>
|
||||||
|
|
||||||
suspend fun rejectInvitation(): Result<Unit>
|
suspend fun rejectInvitation(): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun inviteUserById(id: UserId): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun canInvite(): Result<Boolean>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Room
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomMember
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
|
|
@ -209,6 +210,18 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
innerRoom.inviteUserById(id.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun canInvite(): Result<Boolean> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
innerRoom.member(sessionId.value).use(RoomMember::canInvite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ class RustMatrixTimeline(
|
||||||
RequiredState(key = "m.room.canonical_alias", value = ""),
|
RequiredState(key = "m.room.canonical_alias", value = ""),
|
||||||
RequiredState(key = "m.room.topic", value = ""),
|
RequiredState(key = "m.room.topic", value = ""),
|
||||||
RequiredState(key = "m.room.join_rules", value = ""),
|
RequiredState(key = "m.room.join_rules", value = ""),
|
||||||
|
RequiredState(key = "m.room.power_levels", value = ""),
|
||||||
),
|
),
|
||||||
timelineLimit = null
|
timelineLimit = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ class FakeMatrixRoom(
|
||||||
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
||||||
private var acceptInviteResult = Result.success(Unit)
|
private var acceptInviteResult = Result.success(Unit)
|
||||||
private var rejectInviteResult = Result.success(Unit)
|
private var rejectInviteResult = Result.success(Unit)
|
||||||
|
private var inviteUserResult = Result.success(Unit)
|
||||||
|
private var canInviteResult = Result.success(true)
|
||||||
private var sendMediaResult = Result.success(Unit)
|
private var sendMediaResult = Result.success(Unit)
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
@ -69,6 +71,9 @@ class FakeMatrixRoom(
|
||||||
var isInviteRejected: Boolean = false
|
var isInviteRejected: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var invitedUserId: UserId? = null
|
||||||
|
private set
|
||||||
|
|
||||||
private var leaveRoomError: Throwable? = null
|
private var leaveRoomError: Throwable? = null
|
||||||
|
|
||||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||||
|
|
@ -136,6 +141,15 @@ class FakeMatrixRoom(
|
||||||
return rejectInviteResult
|
return rejectInviteResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun inviteUserById(id: UserId): Result<Unit> {
|
||||||
|
invitedUserId = id
|
||||||
|
return inviteUserResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun canInvite(): Result<Boolean> {
|
||||||
|
return canInviteResult
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
||||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
@ -174,6 +188,14 @@ class FakeMatrixRoom(
|
||||||
rejectInviteResult = result
|
rejectInviteResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenInviteUserResult(result: Result<Unit>) {
|
||||||
|
inviteUserResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenCanInviteResult(result: Result<Boolean>) {
|
||||||
|
canInviteResult = result
|
||||||
|
}
|
||||||
|
|
||||||
fun givenIgnoreResult(result: Result<Unit>) {
|
fun givenIgnoreResult(result: Result<Unit>) {
|
||||||
ignoreResult = result
|
ignoreResult = result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@
|
||||||
<string name="common_file">"File"</string>
|
<string name="common_file">"File"</string>
|
||||||
<string name="common_gif">"GIF"</string>
|
<string name="common_gif">"GIF"</string>
|
||||||
<string name="common_image">"Image"</string>
|
<string name="common_image">"Image"</string>
|
||||||
|
<string name="common_leaving_room">"Leaving room"</string>
|
||||||
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
||||||
<string name="common_loading">"Loading…"</string>
|
<string name="common_loading">"Loading…"</string>
|
||||||
<string name="common_message">"Message"</string>
|
<string name="common_message">"Message"</string>
|
||||||
|
|
@ -98,6 +99,8 @@
|
||||||
<string name="common_suggestions">"Suggestions"</string>
|
<string name="common_suggestions">"Suggestions"</string>
|
||||||
<string name="common_topic">"Topic"</string>
|
<string name="common_topic">"Topic"</string>
|
||||||
<string name="common_unable_to_decrypt">"Unable to decrypt"</string>
|
<string name="common_unable_to_decrypt">"Unable to decrypt"</string>
|
||||||
|
<string name="common_unable_to_invite_message">"We were unable to successfully send invites to one or more users."</string>
|
||||||
|
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string>
|
||||||
<string name="common_unsupported_event">"Unsupported event"</string>
|
<string name="common_unsupported_event">"Unsupported event"</string>
|
||||||
<string name="common_username">"Username"</string>
|
<string name="common_username">"Username"</string>
|
||||||
<string name="common_verification_cancelled">"Verification cancelled"</string>
|
<string name="common_verification_cancelled">"Verification cancelled"</string>
|
||||||
|
|
@ -162,4 +165,4 @@
|
||||||
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
||||||
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
||||||
<string name="screen_report_content_block_user">"Block user"</string>
|
<string name="screen_report_content_block_user">"Block user"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -95,6 +95,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
implementation(project(":services:analytics:noop"))
|
implementation(project(":services:analytics:noop"))
|
||||||
|
implementation(project(":services:apperror:impl"))
|
||||||
implementation(project(":services:appnavstate:impl"))
|
implementation(project(":services:appnavstate:impl"))
|
||||||
implementation(project(":services:toolbox:impl"))
|
implementation(project(":services:toolbox:impl"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
services/apperror/api/build.gradle.kts
Normal file
27
services/apperror/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.services.apperror.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
}
|
||||||
|
|
@ -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.services.apperror.api
|
||||||
|
|
||||||
|
sealed interface AppErrorState {
|
||||||
|
|
||||||
|
object NoError : AppErrorState
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val dismiss: () -> Unit,
|
||||||
|
) : AppErrorState
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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.services.apperror.api
|
||||||
|
|
||||||
|
fun aAppErrorState() = AppErrorState.Error(
|
||||||
|
title = "An error occurred",
|
||||||
|
body = "Something went wrong, and the details of that would go here.",
|
||||||
|
dismiss = {},
|
||||||
|
)
|
||||||
|
|
@ -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.services.apperror.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface AppErrorStateService {
|
||||||
|
|
||||||
|
val appErrorStateFlow: StateFlow<AppErrorState>
|
||||||
|
|
||||||
|
fun showError(title: String, body: String)
|
||||||
|
|
||||||
|
}
|
||||||
51
services/apperror/impl/build.gradle.kts
Normal file
51
services/apperror/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.services.apperror.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
anvil(projects.anvilcodegen)
|
||||||
|
implementation(libs.dagger)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(libs.androidx.corektx)
|
||||||
|
|
||||||
|
api(projects.services.apperror.api)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* 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.services.apperror.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
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.services.apperror.api.AppErrorState
|
||||||
|
import io.element.android.services.apperror.api.aAppErrorState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppErrorView(
|
||||||
|
state: AppErrorState,
|
||||||
|
) {
|
||||||
|
if (state is AppErrorState.Error) {
|
||||||
|
AppErrorViewContent(
|
||||||
|
title = state.title,
|
||||||
|
body = state.body,
|
||||||
|
onDismiss = state.dismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppErrorViewContent(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
onDismiss: () -> Unit = { },
|
||||||
|
) {
|
||||||
|
ErrorDialog(
|
||||||
|
title = title,
|
||||||
|
content = body,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AppErrorViewLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AppErrorViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
AppErrorView(
|
||||||
|
state = aAppErrorState()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.services.apperror.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.services.apperror.api.AppErrorState
|
||||||
|
import io.element.android.services.apperror.api.AppErrorStateService
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService {
|
||||||
|
|
||||||
|
private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
|
||||||
|
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState
|
||||||
|
|
||||||
|
override fun showError(title: String, body: String) {
|
||||||
|
currentAppErrorState.value = AppErrorState.Error(
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
dismiss = {
|
||||||
|
currentAppErrorState.value = AppErrorState.NoError
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* 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.services.apperror.impl
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.services.apperror.api.AppErrorState
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
internal class DefaultAppErrorStateServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial value is no error`() = runTest {
|
||||||
|
val service = DefaultAppErrorStateService()
|
||||||
|
|
||||||
|
service.appErrorStateFlow.test {
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state).isInstanceOf(AppErrorState.NoError::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `showError - emits value`() = runTest {
|
||||||
|
val service = DefaultAppErrorStateService()
|
||||||
|
|
||||||
|
service.appErrorStateFlow.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
service.showError("Title", "Body")
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
|
||||||
|
|
||||||
|
val errorState = state as AppErrorState.Error
|
||||||
|
assertThat(errorState.title).isEqualTo("Title")
|
||||||
|
assertThat(errorState.body).isEqualTo("Body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dismiss - clears value`() = runTest {
|
||||||
|
val service = DefaultAppErrorStateService()
|
||||||
|
|
||||||
|
service.appErrorStateFlow.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
service.showError("Title", "Body")
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
|
||||||
|
|
||||||
|
val errorState = state as AppErrorState.Error
|
||||||
|
errorState.dismiss()
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isInstanceOf(AppErrorState.NoError::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56148beb26b35c8309190271b43fc225e11b90b8b849a8e60abf98b6ab663c1b
|
oid sha256:439a2d9e9497486e86b63fe42402f1c2ee33b15695e47312e18867530d041dad
|
||||||
size 101010
|
size 96539
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9fa60afaf7ab23f66622d7d77e168db8b9d8bc15d178e8adb63f944de3cc29df
|
oid sha256:255b99dea5c1da9d466533d510d7c0abe39a79cdc42b579cfa186176e9c6a49d
|
||||||
size 96242
|
size 91991
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:516e972069292f31625847ca39de06ea402ce1606c97d84f930c18eaa2448cfa
|
||||||
|
size 13759
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b387f10903926ba9ed30b7d213314388eebf23743cbbdbcf4b613cf3136f64aa
|
||||||
|
size 41524
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0af8c7149fd4266c68c0e4b8b9a699bc0b3fc0b05fea3f2e2544abc884f70c5b
|
||||||
|
size 11988
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8eb2fcdb57b7f1ba28990f9576fad6dce156fe0c5c9ae491797040fc22ae4f7b
|
||||||
|
size 39516
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1e4d3bc53e6a86957218247b614be6bbc78cfb35ce49565f2243f50d6ef94fe3
|
||||||
|
size 14216
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:213777e4ace6f9c33209db37bffff2ca6dba13e540e9d07be82239d261ee8e3f
|
||||||
|
size 63951
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8ddec11fd636d345c49cf981ce7498ac2d94027d4b223b257b64288bb0254766
|
||||||
|
size 13328
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:da5ba4637d999c8654e38a86ad0b26c75a6fe10a6988eb821b24cf91da2274e5
|
||||||
|
size 39070
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c57c20fe79d0dbb0ca25571fa2ab3ba6e06f3a9c6634e96fac3ed0155c2ea106
|
||||||
|
size 11160
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8e43279132a7536516cf267599f877f7c729f76be51d6319944f74c57af0e282
|
||||||
|
size 36493
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:933c5ac4478699e9e713af559025ab675a8bdc93b15c42ff27fe5525fa3b668a
|
||||||
|
size 13166
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3a66a39bf18ba35abfc87dea22ca019eea98727bf246142cb034649c5b1772bd
|
||||||
|
size 61041
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:49e7533e6afba903219942193e0cc5cfbe2f67ba3b57516f77c259e7c42e8e3f
|
oid sha256:bbe60787ea821d25168d4a6d75000629c9c0d505a2c8eec9ba3af57790bf4b0a
|
||||||
size 11813
|
size 12863
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
|
oid sha256:49e7533e6afba903219942193e0cc5cfbe2f67ba3b57516f77c259e7c42e8e3f
|
||||||
size 8317
|
size 11813
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
|
oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
|
||||||
size 7733
|
size 8317
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:070c93168058fded5a76e49050f6c4554c7fe483a19aae13c1af4426a6b575f1
|
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
|
||||||
size 30582
|
size 7733
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
|
oid sha256:070c93168058fded5a76e49050f6c4554c7fe483a19aae13c1af4426a6b575f1
|
||||||
size 12841
|
size 30582
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
|
||||||
|
size 12841
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9c3abebbe9e55706af5f4d1089e4308b0e975258a9d6a0e1793a4208b36c9c93
|
oid sha256:925e60112749ce49cf1c2d855406860375357e4717cb7487d3b8bb9a94d7e816
|
||||||
size 11754
|
size 12882
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
|
oid sha256:9c3abebbe9e55706af5f4d1089e4308b0e975258a9d6a0e1793a4208b36c9c93
|
||||||
size 8197
|
size 11754
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
|
oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
|
||||||
size 7514
|
size 8197
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9dd67135f57c6e8a336e238384b02cd6068770b84f429f357d03d5e1e4c22ab6
|
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
|
||||||
size 29286
|
size 7514
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
|
oid sha256:9dd67135f57c6e8a336e238384b02cd6068770b84f429f357d03d5e1e4c22ab6
|
||||||
size 11878
|
size 29286
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
|
||||||
|
size 11878
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a755d1793d5a77244abe66b8a72521d64c46c102e9e9c60d1839d793301526d
|
oid sha256:72f159556f6ccee83c65589b882b3ac9ca9d4f4de8e611f31e19a524e22eab8e
|
||||||
size 39373
|
size 37569
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:04ffb414eeb75e4fb4187777be04c3fc3a9a0967c81a2150ea2d4f58ef4212bc
|
oid sha256:6b1ac0c5e9fc3ecb9870ed3322418c26e26ec0873806d3eb7a89b2a51b39c1f1
|
||||||
size 30599
|
size 28563
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a5ccb63879a3b9a4d37a38903a5b81cf1a185afc638f2d0260b6a6fe68aa2419
|
oid sha256:aa8e818a0c6175cc40b888f8c5a093655b8a352cc2b90e7159d9317de8f68e8f
|
||||||
size 28779
|
size 26778
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a6b5488817b1262bc17800567b3634861230f6db3b81fbe1e91f26d6795a44ae
|
oid sha256:54b897538574ecb5a6e7a13b0f3ae8d0495e8db4667f1878dfc0ca354d0faf47
|
||||||
size 36828
|
size 34903
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0d1d7c27b6c1b3ca495fe794ca888e55f013b8c99b716f4571f8c3c278ed85d7
|
oid sha256:1d5ae33b866c962c0a444b6893dc602b99943a2305a3c3e9695219c1a33388bb
|
||||||
size 38995
|
size 37208
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6a755d1793d5a77244abe66b8a72521d64c46c102e9e9c60d1839d793301526d
|
||||||
|
size 39373
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:333798d610045630944e53c4c43bc401781cc5499ad8c085037239a93de6eccc
|
oid sha256:8912f6213b14f6380359582e96901ee5a1dcdf9f939de3ac8d5bbb6a62f25eaa
|
||||||
size 36950
|
size 35190
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0206a86b0db1d4c2a984435fda5b70335fa3f005cc4c49a3da7d1f3f80fb04e6
|
oid sha256:ad68e0852e7742b58d976a1d8ca2e2da66bf5477c0876ecc9e2c1007cf4b843f
|
||||||
size 29057
|
size 27197
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d55124590cfc79d4c85a35de16223ff7b7821c32742670ea12af0bea05a6ef87
|
oid sha256:048309d5c82ff3ea6dcdd1e5bc81103b5d9147083089f7a006fce840b1d23867
|
||||||
size 27081
|
size 25445
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fadd36fcc359c5c20d21f1525807d07487409f736e9b82d79d261c1e1a48dd4e
|
oid sha256:ff28aa87583eb9ec9966f761deec56757c4cdd68988a37e46af8881affe49802
|
||||||
size 34406
|
size 32555
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:88a68971b697354a92f04de8b73734d1c4ac10792b212dd34d16e16f8dde5ad5
|
oid sha256:3e5ee6f7d37a16a3472eb2a92d611afe270852131f2f330bb3f5ee7db06c1453
|
||||||
size 36572
|
size 34804
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:333798d610045630944e53c4c43bc401781cc5499ad8c085037239a93de6eccc
|
||||||
|
size 36950
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:79739bfd4bd1970e3ea7d5f938c6303f2aae7a4e332651651c26481609d166d4
|
||||||
|
size 7655
|
||||||
Loading…
Add table
Add a link
Reference in a new issue