Benoit Marty
10 months ago
21 changed files with 963 additions and 426 deletions
@ -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. |
||||||
|
*/ |
||||||
|
plugins { |
||||||
|
id("io.element.android-library") |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "io.element.android.libraries.roomselect.api" |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(projects.libraries.architecture) |
||||||
|
implementation(projects.libraries.matrix.api) |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.api |
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext |
||||||
|
import com.bumble.appyx.core.node.Node |
||||||
|
import com.bumble.appyx.core.plugin.Plugin |
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint |
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId |
||||||
|
|
||||||
|
interface RoomSelectEntryPoint : FeatureEntryPoint { |
||||||
|
data class Params( |
||||||
|
val mode: RoomSelectMode, |
||||||
|
) |
||||||
|
|
||||||
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder |
||||||
|
interface NodeBuilder { |
||||||
|
fun params(params: Params): NodeBuilder |
||||||
|
fun callback(callback: Callback): NodeBuilder |
||||||
|
fun build(): Node |
||||||
|
} |
||||||
|
|
||||||
|
interface Callback : Plugin { |
||||||
|
fun onRoomSelected(roomIds: List<RoomId>) |
||||||
|
fun onCancel() |
||||||
|
} |
||||||
|
} |
||||||
|
|
@ -0,0 +1,21 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.api |
||||||
|
|
||||||
|
enum class RoomSelectMode { |
||||||
|
Forward, |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
/* |
||||||
|
* 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.anvil) |
||||||
|
alias(libs.plugins.ksp) |
||||||
|
id("kotlin-parcelize") |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "io.element.android.libraries.roomselect.impl" |
||||||
|
} |
||||||
|
|
||||||
|
anvil { |
||||||
|
generateDaggerFactories.set(true) |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(projects.anvilannotations) |
||||||
|
anvil(projects.anvilcodegen) |
||||||
|
implementation(projects.libraries.core) |
||||||
|
implementation(projects.libraries.androidutils) |
||||||
|
implementation(projects.libraries.architecture) |
||||||
|
implementation(projects.libraries.matrix.api) |
||||||
|
implementation(projects.libraries.matrixui) |
||||||
|
implementation(projects.libraries.designsystem) |
||||||
|
implementation(projects.libraries.uiStrings) |
||||||
|
api(projects.libraries.roomselect.api) |
||||||
|
ksp(libs.showkase.processor) |
||||||
|
|
||||||
|
testImplementation(libs.test.junit) |
||||||
|
testImplementation(libs.coroutines.test) |
||||||
|
testImplementation(libs.molecule.runtime) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(libs.test.turbine) |
||||||
|
testImplementation(projects.libraries.matrix.test) |
||||||
|
testImplementation(projects.tests.testutils) |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext |
||||||
|
import com.bumble.appyx.core.node.Node |
||||||
|
import com.bumble.appyx.core.plugin.Plugin |
||||||
|
import com.squareup.anvil.annotations.ContributesBinding |
||||||
|
import io.element.android.libraries.architecture.createNode |
||||||
|
import io.element.android.libraries.di.SessionScope |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint |
||||||
|
import javax.inject.Inject |
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class) |
||||||
|
class DefaultRoomSelectEntryPoint @Inject constructor() : RoomSelectEntryPoint { |
||||||
|
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomSelectEntryPoint.NodeBuilder { |
||||||
|
val plugins = ArrayList<Plugin>() |
||||||
|
|
||||||
|
return object : RoomSelectEntryPoint.NodeBuilder { |
||||||
|
override fun params(params: RoomSelectEntryPoint.Params): RoomSelectEntryPoint.NodeBuilder { |
||||||
|
plugins += RoomSelectNode.Inputs(mode = params.mode) |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
override fun callback(callback: RoomSelectEntryPoint.Callback): RoomSelectEntryPoint.NodeBuilder { |
||||||
|
plugins += callback |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
override fun build(): Node { |
||||||
|
return parentNode.createNode<RoomSelectNode>(buildContext, plugins) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
@ -0,0 +1,28 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||||
|
|
||||||
|
sealed interface RoomSelectEvents { |
||||||
|
data class SetSelectedRoom(val room: RoomSummaryDetails) : RoomSelectEvents |
||||||
|
|
||||||
|
// TODO remove to restore multi-selection |
||||||
|
data object RemoveSelectedRoom : RoomSelectEvents |
||||||
|
data object ToggleSearchActive : RoomSelectEvents |
||||||
|
data class UpdateQuery(val query: String) : RoomSelectEvents |
||||||
|
} |
@ -0,0 +1,67 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import com.bumble.appyx.core.modality.BuildContext |
||||||
|
import com.bumble.appyx.core.node.Node |
||||||
|
import com.bumble.appyx.core.plugin.Plugin |
||||||
|
import dagger.assisted.Assisted |
||||||
|
import dagger.assisted.AssistedInject |
||||||
|
import io.element.android.anvilannotations.ContributesNode |
||||||
|
import io.element.android.libraries.architecture.NodeInputs |
||||||
|
import io.element.android.libraries.architecture.inputs |
||||||
|
import io.element.android.libraries.di.SessionScope |
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class) |
||||||
|
class RoomSelectNode @AssistedInject constructor( |
||||||
|
@Assisted buildContext: BuildContext, |
||||||
|
@Assisted plugins: List<Plugin>, |
||||||
|
presenterFactory: RoomSelectPresenter.Factory, |
||||||
|
) : Node(buildContext, plugins = plugins) { |
||||||
|
data class Inputs( |
||||||
|
val mode: RoomSelectMode, |
||||||
|
) : NodeInputs |
||||||
|
|
||||||
|
private val inputs: Inputs = inputs() |
||||||
|
private val presenter = presenterFactory.create(inputs.mode) |
||||||
|
|
||||||
|
private val callbacks = plugins.filterIsInstance<RoomSelectEntryPoint.Callback>() |
||||||
|
|
||||||
|
private fun onDismiss() { |
||||||
|
callbacks.forEach { it.onCancel() } |
||||||
|
} |
||||||
|
|
||||||
|
private fun onSubmit(roomIds: List<RoomId>) { |
||||||
|
callbacks.forEach { it.onRoomSelected(roomIds) } |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
override fun View(modifier: Modifier) { |
||||||
|
val state = presenter.present() |
||||||
|
RoomSelectView( |
||||||
|
state = state, |
||||||
|
onDismiss = ::onDismiss, |
||||||
|
onSubmit = ::onSubmit, |
||||||
|
modifier = modifier |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,98 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.LaunchedEffect |
||||||
|
import androidx.compose.runtime.collectAsState |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import dagger.assisted.Assisted |
||||||
|
import dagger.assisted.AssistedFactory |
||||||
|
import dagger.assisted.AssistedInject |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
import io.element.android.libraries.architecture.Presenter |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummary |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
import kotlinx.collections.immutable.persistentListOf |
||||||
|
import kotlinx.collections.immutable.toPersistentList |
||||||
|
|
||||||
|
class RoomSelectPresenter @AssistedInject constructor( |
||||||
|
@Assisted private val mode: RoomSelectMode, |
||||||
|
private val client: MatrixClient, |
||||||
|
) : Presenter<RoomSelectState> { |
||||||
|
|
||||||
|
@AssistedFactory |
||||||
|
interface Factory { |
||||||
|
fun create(mode: RoomSelectMode): RoomSelectPresenter |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
override fun present(): RoomSelectState { |
||||||
|
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) } |
||||||
|
var query by remember { mutableStateOf("") } |
||||||
|
var isSearchActive by remember { mutableStateOf(false) } |
||||||
|
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } |
||||||
|
|
||||||
|
val summaries by client.roomListService.allRooms.summaries.collectAsState() |
||||||
|
|
||||||
|
LaunchedEffect(query, summaries) { |
||||||
|
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>() |
||||||
|
.map { it.details } |
||||||
|
.filter { it.name.contains(query, ignoreCase = true) } |
||||||
|
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received |
||||||
|
.toPersistentList() |
||||||
|
results = if (filteredSummaries.isNotEmpty()) { |
||||||
|
SearchBarResultState.Results(filteredSummaries) |
||||||
|
} else { |
||||||
|
SearchBarResultState.NoResults() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun handleEvents(event: RoomSelectEvents) { |
||||||
|
when (event) { |
||||||
|
is RoomSelectEvents.SetSelectedRoom -> { |
||||||
|
selectedRooms = persistentListOf(event.room) |
||||||
|
// Restore for multi-selection |
||||||
|
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } |
||||||
|
// selectedRooms = if (index >= 0) { |
||||||
|
// selectedRooms.removeAt(index) |
||||||
|
// } else { |
||||||
|
// selectedRooms.add(event.room) |
||||||
|
// } |
||||||
|
} |
||||||
|
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() |
||||||
|
is RoomSelectEvents.UpdateQuery -> query = event.query |
||||||
|
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return RoomSelectState( |
||||||
|
mode = mode, |
||||||
|
resultState = results, |
||||||
|
query = query, |
||||||
|
isSearchActive = isSearchActive, |
||||||
|
selectedRooms = selectedRooms, |
||||||
|
eventSink = { handleEvents(it) } |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,31 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
|
||||||
|
data class RoomSelectState( |
||||||
|
val mode: RoomSelectMode, |
||||||
|
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>, |
||||||
|
val query: String, |
||||||
|
val isSearchActive: Boolean, |
||||||
|
val selectedRooms: ImmutableList<RoomSummaryDetails>, |
||||||
|
val eventSink: (RoomSelectEvents) -> Unit |
||||||
|
) |
@ -0,0 +1,89 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId |
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember |
||||||
|
import io.element.android.libraries.matrix.api.room.message.RoomMessage |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
import kotlinx.collections.immutable.persistentListOf |
||||||
|
|
||||||
|
open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> { |
||||||
|
override val values: Sequence<RoomSelectState> |
||||||
|
get() = sequenceOf( |
||||||
|
aRoomSelectState(), |
||||||
|
aRoomSelectState(query = "Test", isSearchActive = true), |
||||||
|
aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), |
||||||
|
aRoomSelectState( |
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), |
||||||
|
query = "Test", |
||||||
|
isSearchActive = true, |
||||||
|
), |
||||||
|
aRoomSelectState( |
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), |
||||||
|
query = "Test", |
||||||
|
isSearchActive = true, |
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) |
||||||
|
), |
||||||
|
// Add other states here |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
fun aRoomSelectState( |
||||||
|
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(), |
||||||
|
query: String = "", |
||||||
|
isSearchActive: Boolean = false, |
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(), |
||||||
|
) = RoomSelectState( |
||||||
|
mode = RoomSelectMode.Forward, |
||||||
|
resultState = resultState, |
||||||
|
query = query, |
||||||
|
isSearchActive = isSearchActive, |
||||||
|
selectedRooms = selectedRooms, |
||||||
|
eventSink = {} |
||||||
|
) |
||||||
|
|
||||||
|
internal fun aForwardMessagesRoomList() = persistentListOf( |
||||||
|
aRoomDetailsState(), |
||||||
|
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"), |
||||||
|
) |
||||||
|
|
||||||
|
fun aRoomDetailsState( |
||||||
|
roomId: RoomId = RoomId("!room:domain"), |
||||||
|
name: String = "roomName", |
||||||
|
canonicalAlias: String? = null, |
||||||
|
isDirect: Boolean = true, |
||||||
|
avatarURLString: String? = null, |
||||||
|
lastMessage: RoomMessage? = null, |
||||||
|
lastMessageTimestamp: Long? = null, |
||||||
|
unreadNotificationCount: Int = 0, |
||||||
|
inviter: RoomMember? = null, |
||||||
|
) = RoomSummaryDetails( |
||||||
|
roomId = roomId, |
||||||
|
name = name, |
||||||
|
canonicalAlias = canonicalAlias, |
||||||
|
isDirect = isDirect, |
||||||
|
avatarURLString = avatarURLString, |
||||||
|
lastMessage = lastMessage, |
||||||
|
lastMessageTimestamp = lastMessageTimestamp, |
||||||
|
unreadNotificationCount = unreadNotificationCount, |
||||||
|
inviter = inviter, |
||||||
|
) |
@ -0,0 +1,267 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.PaddingValues |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.heightIn |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.lazy.LazyColumn |
||||||
|
import androidx.compose.foundation.lazy.LazyRow |
||||||
|
import androidx.compose.foundation.lazy.items |
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api |
||||||
|
import androidx.compose.material3.MaterialTheme |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.text.style.TextOverflow |
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.compound.theme.ElementTheme |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar |
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData |
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize |
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle |
||||||
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider |
||||||
|
import io.element.android.libraries.designsystem.theme.components.RadioButton |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBar |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton |
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar |
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomMessage |
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomName |
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||||
|
import io.element.android.libraries.matrix.ui.components.SelectedRoom |
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||||
|
@Composable |
||||||
|
fun RoomSelectView( |
||||||
|
state: RoomSelectState, |
||||||
|
onDismiss: () -> Unit, |
||||||
|
onSubmit: (List<RoomId>) -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
@Suppress("UNUSED_PARAMETER") |
||||||
|
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { |
||||||
|
// TODO toggle selection when multi-selection is enabled |
||||||
|
state.eventSink(RoomSelectEvents.RemoveSelectedRoom) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) { |
||||||
|
if (isForwarding) return |
||||||
|
SelectedRooms( |
||||||
|
selectedRooms = selectedRooms, |
||||||
|
onRoomRemoved = ::onRoomRemoved, |
||||||
|
modifier = Modifier.padding(vertical = 16.dp) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
fun onBackButton(state: RoomSelectState) { |
||||||
|
if (state.isSearchActive) { |
||||||
|
state.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||||
|
} else { |
||||||
|
onDismiss() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
BackHandler(onBack = { onBackButton(state) }) |
||||||
|
|
||||||
|
Scaffold( |
||||||
|
modifier = modifier, |
||||||
|
topBar = { |
||||||
|
TopAppBar( |
||||||
|
title = { |
||||||
|
Text( |
||||||
|
text = when (state.mode) { |
||||||
|
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) |
||||||
|
}, |
||||||
|
style = ElementTheme.typography.aliasScreenTitle |
||||||
|
) |
||||||
|
}, |
||||||
|
navigationIcon = { |
||||||
|
BackButton(onClick = { onBackButton(state) }) |
||||||
|
}, |
||||||
|
actions = { |
||||||
|
TextButton( |
||||||
|
text = stringResource(CommonStrings.action_send), |
||||||
|
enabled = state.selectedRooms.isNotEmpty(), |
||||||
|
onClick = { onSubmit(state.selectedRooms.map { it.roomId }) } |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
) { paddingValues -> |
||||||
|
Column( |
||||||
|
Modifier |
||||||
|
.padding(paddingValues) |
||||||
|
.consumeWindowInsets(paddingValues) |
||||||
|
) { |
||||||
|
SearchBar( |
||||||
|
placeHolderTitle = stringResource(CommonStrings.action_search), |
||||||
|
query = state.query, |
||||||
|
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) }, |
||||||
|
active = state.isSearchActive, |
||||||
|
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) }, |
||||||
|
resultState = state.resultState, |
||||||
|
showBackButton = false, |
||||||
|
) { summaries -> |
||||||
|
LazyColumn { |
||||||
|
item { |
||||||
|
SelectedRoomsHelper( |
||||||
|
isForwarding = false, // TODO state.isForwarding, |
||||||
|
selectedRooms = state.selectedRooms |
||||||
|
) |
||||||
|
} |
||||||
|
items(summaries, key = { it.roomId.value }) { roomSummary -> |
||||||
|
Column { |
||||||
|
RoomSummaryView( |
||||||
|
roomSummary, |
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, |
||||||
|
onSelection = { roomSummary -> |
||||||
|
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) |
||||||
|
} |
||||||
|
) |
||||||
|
HorizontalDivider(modifier = Modifier.fillMaxWidth()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!state.isSearchActive) { |
||||||
|
// TODO restore for multi-selection |
||||||
|
// SelectedRoomsHelper( |
||||||
|
// isForwarding = state.isForwarding, |
||||||
|
// selectedRooms = state.selectedRooms |
||||||
|
// ) |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
|
||||||
|
if (state.resultState is SearchBarResultState.Results) { |
||||||
|
LazyColumn { |
||||||
|
items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> |
||||||
|
Column { |
||||||
|
RoomSummaryView( |
||||||
|
roomSummary, |
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, |
||||||
|
onSelection = { roomSummary -> |
||||||
|
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) |
||||||
|
} |
||||||
|
) |
||||||
|
HorizontalDivider(modifier = Modifier.fillMaxWidth()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun SelectedRooms( |
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails>, |
||||||
|
onRoomRemoved: (RoomSummaryDetails) -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
LazyRow( |
||||||
|
modifier, |
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp), |
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp) |
||||||
|
) { |
||||||
|
items(selectedRooms, key = { it.roomId.value }) { roomSummary -> |
||||||
|
SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun RoomSummaryView( |
||||||
|
summary: RoomSummaryDetails, |
||||||
|
isSelected: Boolean, |
||||||
|
onSelection: (RoomSummaryDetails) -> Unit, |
||||||
|
modifier: Modifier = Modifier |
||||||
|
) { |
||||||
|
Row( |
||||||
|
modifier = modifier |
||||||
|
.clickable { onSelection(summary) } |
||||||
|
.fillMaxWidth() |
||||||
|
.padding(start = 16.dp, end = 4.dp) |
||||||
|
.heightIn(56.dp), |
||||||
|
verticalAlignment = Alignment.CenterVertically |
||||||
|
) { |
||||||
|
val roomAlias = summary.canonicalAlias ?: summary.roomId.value |
||||||
|
Avatar( |
||||||
|
avatarData = AvatarData( |
||||||
|
id = roomAlias, |
||||||
|
name = summary.name, |
||||||
|
url = summary.avatarURLString, |
||||||
|
size = AvatarSize.ForwardRoomListItem, |
||||||
|
), |
||||||
|
) |
||||||
|
Column( |
||||||
|
modifier = Modifier |
||||||
|
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) |
||||||
|
.weight(1f) |
||||||
|
) { |
||||||
|
// Name |
||||||
|
Text( |
||||||
|
style = ElementTheme.typography.fontBodyLgRegular, |
||||||
|
text = summary.name, |
||||||
|
color = MaterialTheme.roomListRoomName(), |
||||||
|
maxLines = 1, |
||||||
|
overflow = TextOverflow.Ellipsis |
||||||
|
) |
||||||
|
// Id |
||||||
|
Text( |
||||||
|
text = roomAlias, |
||||||
|
color = MaterialTheme.roomListRoomMessage(), |
||||||
|
style = ElementTheme.typography.fontBodySmRegular, |
||||||
|
maxLines = 1, |
||||||
|
overflow = TextOverflow.Ellipsis |
||||||
|
) |
||||||
|
} |
||||||
|
RadioButton(selected = isSelected, onClick = { onSelection(summary) }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@PreviewsDayNight |
||||||
|
@Composable |
||||||
|
internal fun RoomSelectViewPreview(@PreviewParameter(RoomSelectStateProvider::class) state: RoomSelectState) = ElementPreview { |
||||||
|
RoomSelectView( |
||||||
|
state = state, |
||||||
|
onDismiss = {}, |
||||||
|
onSubmit = {}, |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,117 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.roomselect.impl |
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode |
||||||
|
import app.cash.molecule.moleculeFlow |
||||||
|
import app.cash.turbine.test |
||||||
|
import com.google.common.truth.Truth.assertThat |
||||||
|
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummary |
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient |
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail |
||||||
|
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService |
||||||
|
import io.element.android.tests.testutils.WarmUpRule |
||||||
|
import kotlinx.collections.immutable.persistentListOf |
||||||
|
import kotlinx.coroutines.test.runTest |
||||||
|
import org.junit.Rule |
||||||
|
import org.junit.Test |
||||||
|
|
||||||
|
class RoomSelectPresenterTests { |
||||||
|
|
||||||
|
@get:Rule |
||||||
|
val warmUpRule = WarmUpRule() |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - initial state`() = runTest { |
||||||
|
val presenter = aPresenter() |
||||||
|
moleculeFlow(RecompositionMode.Immediate) { |
||||||
|
presenter.present() |
||||||
|
}.test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(initialState.selectedRooms).isEmpty() |
||||||
|
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) |
||||||
|
assertThat(initialState.isSearchActive).isFalse() |
||||||
|
// Search is run automatically |
||||||
|
val searchState = awaitItem() |
||||||
|
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - toggle search active`() = runTest { |
||||||
|
val presenter = aPresenter() |
||||||
|
moleculeFlow(RecompositionMode.Immediate) { |
||||||
|
presenter.present() |
||||||
|
}.test { |
||||||
|
val initialState = awaitItem() |
||||||
|
skipItems(1) |
||||||
|
|
||||||
|
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||||
|
assertThat(awaitItem().isSearchActive).isTrue() |
||||||
|
|
||||||
|
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||||
|
assertThat(awaitItem().isSearchActive).isFalse() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - update query`() = runTest { |
||||||
|
val roomListService = FakeRoomListService().apply { |
||||||
|
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) |
||||||
|
} |
||||||
|
val client = FakeMatrixClient(roomListService = roomListService) |
||||||
|
val presenter = aPresenter(client = client) |
||||||
|
moleculeFlow(RecompositionMode.Immediate) { |
||||||
|
presenter.present() |
||||||
|
}.test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) |
||||||
|
|
||||||
|
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) |
||||||
|
assertThat(awaitItem().query).isEqualTo("string not contained") |
||||||
|
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - select and remove a room`() = runTest { |
||||||
|
val presenter = aPresenter() |
||||||
|
moleculeFlow(RecompositionMode.Immediate) { |
||||||
|
presenter.present() |
||||||
|
}.test { |
||||||
|
val initialState = awaitItem() |
||||||
|
skipItems(1) |
||||||
|
val summary = aRoomSummaryDetail() |
||||||
|
|
||||||
|
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary)) |
||||||
|
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) |
||||||
|
|
||||||
|
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) |
||||||
|
assertThat(awaitItem().selectedRooms).isEmpty() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun aPresenter( |
||||||
|
mode: RoomSelectMode = RoomSelectMode.Forward, |
||||||
|
client: FakeMatrixClient = FakeMatrixClient(), |
||||||
|
) = RoomSelectPresenter( |
||||||
|
mode = mode, |
||||||
|
client = client, |
||||||
|
) |
||||||
|
} |
Loading…
Reference in new issue