[Room Details] Implement room details screen (#256)

* Implement Room Details screen

* Add option to create permalink from room id and alias, add share room action
This commit is contained in:
Jorge Martin Espinosa 2023-03-29 07:16:27 +02:00 committed by GitHub
parent 997bf87340
commit efba97e145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 1203 additions and 117 deletions

View File

@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.features.verifysession.api)
implementation(projects.features.roomdetails.api)
implementation(projects.tests.uitests)
implementation(libs.coil)

View File

@ -26,12 +26,15 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -44,6 +47,7 @@ class RoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
) : BackstackNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
@ -84,7 +88,14 @@ class RoomFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Messages -> {
messagesEntryPoint.createNode(this, buildContext)
messagesEntryPoint.createNode(this, buildContext, object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails)
}
})
}
NavTarget.RoomDetails -> {
roomDetailsEntryPoint.createNode(this, buildContext)
}
}
}
@ -92,6 +103,9 @@ class RoomFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
@Parcelize
object RoomDetails : NavTarget
}
@Composable
@ -99,6 +113,7 @@ class RoomFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@ -245,6 +245,7 @@ koverMerged {
includes += "*State"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
}
bound {
minValue = 90

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

@ -0,0 +1 @@
Implement Room Details screen

View File

@ -26,4 +26,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

@ -16,6 +16,19 @@
package io.element.android.features.messages.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
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
interface MessagesEntryPoint : SimpleFeatureEntryPoint
interface MessagesEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onRoomDetailsClicked()
}
}

View File

@ -18,15 +18,21 @@ package io.element.android.features.messages.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.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<MessagesNode>(buildContext)
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: MessagesEntryPoint.Callback
): Node {
return parentNode.createNode<MessagesNode>(buildContext, listOf(callback))
}
}

View File

@ -21,10 +21,13 @@ 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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor(
@ -33,12 +36,19 @@ class MessagesNode @AssistedInject constructor(
private val presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) {
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
private fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked()
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MessagesView(
state = state,
onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked,
modifier = modifier
)
}

View File

@ -21,6 +21,7 @@
package io.element.android.features.messages.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -69,6 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.launch
import timber.log.Timber
@ -77,6 +79,7 @@ fun MessagesView(
state: MessagesState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onRoomDetailsClicked: () -> Unit = {},
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
@ -112,7 +115,8 @@ fun MessagesView(
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
)
},
content = { padding ->
@ -174,6 +178,7 @@ fun MessagesViewTopBar(
roomTitle: String?,
roomAvatar: AvatarData?,
modifier: Modifier = Modifier,
onRoomDetailsClicked: () -> Unit = {},
onBackPressed: () -> Unit = {},
) {
TopAppBar(
@ -187,7 +192,10 @@ fun MessagesViewTopBar(
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.clickable { onRoomDetailsClicked() },
verticalAlignment = Alignment.CenterVertically
) {
if (roomAvatar != null) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))

View File

@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Attach screenshot"</string>
<string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions"</string>
<string name="screen_bug_report_edit_screenshot">"Edit screenshot"</string>
<string name="screen_bug_report_editor_description">"Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can."</string>
<string name="screen_bug_report_editor_placeholder">"Describe the bug…"</string>
<string name="screen_bug_report_editor_supporting">"If possible, please write the description in English."</string>

1
features/roomdetails/api/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,30 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.roomdetails.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
}

1
features/roomdetails/impl/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,57 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.roomdetails.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api)
implementation(libs.coil.compose)
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)
ksp(libs.showkase.processor)
}

View File

@ -0,0 +1,32 @@
/*
* 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext)
}
}

View File

@ -0,0 +1,19 @@
/*
* 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
sealed interface RoomDetailsEvent

View File

@ -0,0 +1,68 @@
/*
* 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
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomDetails,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomDetails : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.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.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.ui.strings.R as StringR
@ContributesNode(RoomScope::class)
class RoomDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomDetailsPresenter,
private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins) {
private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
?: PermalinkBuilder.permalinkForRoomId(room.roomId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,
noActivityFoundMessage = context.getString(StringR.string.error_no_compatible_app_found)
)
}
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val state = presenter.present()
RoomDetailsView(
state = state,
modifier = modifier,
goBack = { navigateUp() },
onShareRoom = { onShareRoom(context) },
)
}
}

View File

@ -0,0 +1,43 @@
/*
* 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
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
// fun handleEvents(event: RoomDetailsEvent) {}
return RoomDetailsState(
roomId = room.roomId.value,
roomName = room.name ?: room.displayName,
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic,
memberCount = room.members.size,
isEncrypted = room.isEncrypted,
// eventSink = ::handleEvents
)
}
}

View File

@ -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.features.roomdetails.impl
data class RoomDetailsState(
val roomId: String,
val roomName: String,
val roomAlias: String?,
val roomAvatarUrl: String?,
val roomTopic: String?,
val memberCount: Int,
val isEncrypted: Boolean,
// val eventSink: (RoomDetailsEvent) -> Unit
)

View File

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(),
aRoomDetailsState().copy(roomTopic = null),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
// Add other state here
)
}
fun aRoomDetailsState() = RoomDetailsState(
roomId = "a room id",
roomName = "Marketing",
roomAlias = "#marketing:domain.com",
roomAvatarUrl = null,
roomTopic = "Welcome to #marketing, home of the Marketing team " +
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing...",
memberCount = 32,
isEncrypted = true,
// eventSink = {}
)

View File

@ -0,0 +1,204 @@
/*
* 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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles
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.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomDetailsView(
state: RoomDetailsState,
goBack: () -> Unit,
onShareRoom: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
},
) { padding ->
Column(modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
HeaderSection(
avatarUrl = state.roomAvatarUrl,
roomId = state.roomId,
roomName = state.roomName,
roomAlias = state.roomAlias
)
ShareSection(onShareRoom = onShareRoom)
if (state.roomTopic != null) {
TopicSection(roomTopic = state.roomTopic)
}
MembersSection(memberCount = state.memberCount)
if (state.isEncrypted) {
SecuritySection()
}
OtherActionsSection()
}
}
}
@Composable
internal fun ShareSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_share_room_title),
icon = Icons.Outlined.Share,
onClick = onShareRoom,
)
}
}
@Composable
internal fun HeaderSection(
avatarUrl: String?,
roomId: String,
roomName: String,
roomAlias: String?,
modifier: Modifier = Modifier
) {
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(70.dp)) {
Avatar(
avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.HUGE),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.height(30.dp))
Text(roomName, style = ElementTextStyles.Bold.title1)
if (roomAlias != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(roomAlias, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary)
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
PreferenceCategory(title = stringResource(R.string.screen_room_details_topic_title), modifier = modifier) {
Text(
roomTopic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
@Composable
internal fun MembersSection(memberCount: Int, modifier: Modifier = Modifier) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_people_title),
icon = Icons.Outlined.Person,
currentValue = memberCount.toString(),
)
PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title),
icon = Icons.Outlined.PersonAddAlt,
)
}
}
@Composable
internal fun SecuritySection(modifier: Modifier = Modifier) {
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title), modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_encryption_enabled_title),
subtitle = stringResource(R.string.screen_room_details_encryption_enabled_subtitle),
icon = Icons.Outlined.Lock,
)
}
}
@Composable
internal fun OtherActionsSection(modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_leave_room_title),
icon = ImageVector.vectorResource(R.drawable.ic_door_open),
tintColor = LocalColors.current.textActionCritical,
)
}
}
@Preview
@Composable
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomDetailsState) {
RoomDetailsView(
state = state,
goBack = {},
onShareRoom = {},
)
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520Q457,520 468.5,508.5Q480,497 480,480Q480,463 468.5,451.5Q457,440 440,440Q423,440 411.5,451.5Q400,463 400,480Q400,497 411.5,508.5Q423,520 440,520ZM280,840L280,760L520,720Q520,720 520,720Q520,720 520,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,718Q600,747 581,769.5Q562,792 533,797L280,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800Q120,783 131.5,771.5Q143,760 160,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L800,760Q817,760 828.5,771.5Q840,783 840,800Q840,817 828.5,828.5Q817,840 800,840L160,840Z"/>
</vector>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_topic_title">"Topic"</string>
</resources>

View File

@ -0,0 +1,79 @@
/*
* 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
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomDetailsPresenterTests {
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomId).isEqualTo(room.roomId.value)
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(room.members.count())
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
}
}
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName)
}
}
}
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
displayName: String = "A fallback display name",
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true,
) = FakeMatrixRoom(
roomId = roomId,
name = name,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,
members = members,
isEncrypted = isEncrypted,
)

View File

@ -69,3 +69,6 @@ val ElementOrange = Color(0xFFD9B072)
val Vermilion = Color(0xFFFF5B55)
val LinkColor = Color(0xFF0086E6)
val TextColorCriticalLight = Color(0xFFD51928)
val TextColorCriticalDark = Color(0xfffd3e3c)

View File

@ -28,3 +28,12 @@ fun Boolean.toEnabledColor(): Color {
MaterialTheme.colorScheme.primary.copy(alpha = 0.40f)
}
}
@Composable
fun Boolean.toSecondaryEnabledColor(): Color {
return if (this) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.40f)
}
}

View File

@ -18,5 +18,7 @@ package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.ui.unit.dp
internal val preferenceMinHeight = 80.dp
internal val preferencePaddingEnd = 16.dp
internal val preferenceMinHeightOnlyTitle = 48.dp
internal val preferenceMinHeight = 64.dp
internal val preferencePaddingHorizontal = 16.dp
internal val preferencePaddingVertical = 16.dp

View File

@ -35,29 +35,35 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
title: String? = null,
showDivider: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
) {
Divider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp
)
Text(
modifier = Modifier.padding(top = 8.dp, start = 56.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
text = title,
)
if (title != null) {
PreferenceCategoryTitle(title = title)
}
content()
if (showDivider) {
PreferenceDivider()
}
}
}
@Composable
fun PreferenceCategoryTitle(title: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
text = title,
)
}
@Preview
@Composable
internal fun PreferenceCategoryLightPreview() = ElementPreviewLight { ContentToPreview() }

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Divider
@Composable
fun PreferenceDivider(modifier: Modifier = Modifier) {
Divider(modifier, thickness = 0.5.dp)
}

View File

@ -29,7 +29,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Announcement
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@ -39,10 +38,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -92,12 +90,7 @@ fun PreferenceTopAppBar(
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
BackButton(onClick = onBackPressed)
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -132,13 +125,16 @@ private fun ContentToPreview() {
) {
PreferenceText(
title = "Title",
subtitle = "Some other text",
icon = Icons.Default.BugReport,
)
PreferenceDivider()
PreferenceSwitch(
title = "Switch",
icon = Icons.Default.Announcement,
isChecked = true
isChecked = true,
)
PreferenceDivider()
PreferenceSlide(
title = "Slide",
summary = "Summary",

View File

@ -23,9 +23,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
@ -51,15 +52,15 @@ fun PreferenceSlide(
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = preferenceMinHeight),
contentAlignment = Alignment.CenterStart
.defaultMinSize(minHeight = preferenceMinHeight)
.padding(top = preferencePaddingVertical),
) {
Row(modifier = Modifier.fillMaxWidth()) {
PreferenceIcon(icon = icon)
Column(
modifier = Modifier
.weight(1f)
.padding(end = preferencePaddingEnd),
.padding(end = preferencePaddingHorizontal),
) {
Text(
modifier = Modifier.fillMaxWidth(),
@ -97,6 +98,7 @@ internal fun PreferenceSlideDarkPreview() = ElementPreviewDark { ContentToPrevie
@Composable
private fun ContentToPreview() {
PreferenceSlide(
icon = Icons.Default.Person,
title = "Slide",
summary = "Summary",
value = 0.75F

View File

@ -53,22 +53,20 @@ fun PreferenceSwitch(
.clickable { onCheckedChange(!isChecked) },
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(modifier = Modifier.fillMaxWidth()) {
PreferenceIcon(
modifier = Modifier.padding(vertical = preferencePaddingVertical),
icon = icon,
enabled = enabled
)
Text(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).padding(vertical = preferencePaddingVertical),
style = MaterialTheme.typography.bodyLarge,
color = enabled.toEnabledColor(),
text = title
)
Checkbox(
modifier = Modifier.padding(end = preferencePaddingEnd),
modifier = Modifier.padding(end = preferencePaddingHorizontal).align(Alignment.CenterVertically),
checked = isChecked,
enabled = enabled,
onCheckedChange = onCheckedChange

View File

@ -18,18 +18,24 @@ package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -37,31 +43,52 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun PreferenceText(
title: String,
// TODO subtitle
title: String?,
modifier: Modifier = Modifier,
subtitle: String? = null,
currentValue: String? = null,
icon: ImageVector? = null,
tintColor: Color? = null,
onClick: () -> Unit = {},
) {
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = preferenceMinHeight)
.defaultMinSize(minHeight = minHeight)
.padding(end = preferencePaddingHorizontal)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(vertical = preferencePaddingVertical)
) {
PreferenceIcon(icon = icon)
Text(
modifier = Modifier
.weight(1f)
.padding(end = preferencePaddingEnd),
style = MaterialTheme.typography.bodyLarge,
text = title,
color = MaterialTheme.colorScheme.primary,
)
PreferenceIcon(icon = icon, tintColor = tintColor)
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
if (title != null) {
Text(
style = MaterialTheme.typography.bodyLarge,
text = title,
color = tintColor ?: MaterialTheme.colorScheme.primary,
)
}
if (title != null && subtitle != null) {
Spacer(modifier = Modifier.height(8.dp))
}
if (subtitle != null) {
Text(
style = MaterialTheme.typography.bodySmall,
text = subtitle,
color = tintColor ?: MaterialTheme.colorScheme.tertiary,
)
}
}
if (currentValue != null) {
Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp))
}
}
}
}
@ -78,6 +105,7 @@ internal fun PreferenceTextDarkPreview() = ElementPreviewDark { ContentToPreview
private fun ContentToPreview() {
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
)
}

View File

@ -17,10 +17,12 @@
package io.element.android.libraries.designsystem.components.preferences.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -28,25 +30,30 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
@Composable
fun PreferenceIcon(
icon: ImageVector?,
modifier: Modifier = Modifier,
enabled: Boolean = true
tintColor: Color? = null,
enabled: Boolean = true,
isVisible: Boolean = true,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = "",
tint = enabled.toEnabledColor(),
tint = tintColor ?: enabled.toSecondaryEnabledColor(),
modifier = modifier
.padding(start = 8.dp)
.width(48.dp),
.width(48.dp)
.heightIn(max = 48.dp),
)
} else {
} else if (isVisible) {
Spacer(modifier = modifier.width(56.dp))
} else {
Spacer(modifier = modifier.width(16.dp))
}
}

View File

@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Gray_450
import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark
import io.element.android.libraries.designsystem.TextColorCriticalDark
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
fun elementColorsDark() = ElementColors(
@ -37,6 +38,7 @@ fun elementColorsDark() = ElementColors(
messageHighlightedBackground = Azure,
quaternary = Gray_400,
quinary = Gray_450,
textActionCritical = TextColorCriticalDark,
isLight = false,
)
@ -69,7 +71,7 @@ val materialColorSchemeDark = darkColorScheme(
// TODO errorContainer = ColorDarkTokens.ErrorContainer,
// TODO onErrorContainer = ColorDarkTokens.OnErrorContainer,
// TODO outline = ColorDarkTokens.Outline,
// TODO outlineVariant = ColorDarkTokens.OutlineVariant,
outlineVariant = Gray_450,
// TODO scrim = ColorDarkTokens.Scrim,
)

View File

@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light
import io.element.android.libraries.designsystem.TextColorCriticalLight
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
fun elementColorsLight() = ElementColors(
@ -37,6 +38,7 @@ fun elementColorsLight() = ElementColors(
messageHighlightedBackground = Azure,
quaternary = Gray_100,
quinary = Gray_50,
textActionCritical = TextColorCriticalLight,
isLight = true,
)
@ -69,7 +71,7 @@ val materialColorSchemeLight = lightColorScheme(
// TODO errorContainer = ColorLightTokens.ErrorContainer,
// TODO onErrorContainer = ColorLightTokens.OnErrorContainer,
// TODO outline = ColorLightTokens.Outline,
// TODO outlineVariant = ColorLightTokens.OutlineVariant,
outlineVariant = Gray_50,
// TODO scrim = ColorLightTokens.Scrim,
)

View File

@ -29,7 +29,8 @@ class ElementColors(
messageHighlightedBackground: Color,
quaternary: Color,
quinary: Color,
isLight: Boolean,
textActionCritical: Color,
isLight: Boolean
) {
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
private set
@ -44,6 +45,9 @@ class ElementColors(
var quinary by mutableStateOf(quinary)
private set
var textActionCritical by mutableStateOf(textActionCritical)
private set
var isLight by mutableStateOf(isLight)
private set
@ -53,6 +57,7 @@ class ElementColors(
messageHighlightedBackground: Color = this.messageHighlightedBackground,
quaternary: Color = this.quaternary,
quinary: Color = this.quinary,
textActionCritical: Color = this.textActionCritical,
isLight: Boolean = this.isLight,
) = ElementColors(
messageFromMeBackground = messageFromMeBackground,
@ -60,6 +65,7 @@ class ElementColors(
messageHighlightedBackground = messageHighlightedBackground,
quaternary = quaternary,
quinary = quinary,
textActionCritical = textActionCritical,
isLight = isLight,
)
@ -69,6 +75,7 @@ class ElementColors(
messageHighlightedBackground = other.messageHighlightedBackground
quaternary = other.quaternary
quinary = other.quinary
textActionCritical = other.textActionCritical
isLight = other.isLight
}
}

View File

@ -38,7 +38,7 @@ fun TopAppBar(
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = TopAppBarDefaults.smallTopAppBarColors(),
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null
) {
androidx.compose.material3.TopAppBar(

View File

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.config
object MatrixConfiguration {
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
val clientPermalinkBaseUrl: String? = null
}

View File

@ -17,14 +17,13 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
/**
* Mapping of an input URI to a matrix.to compliant URI.
*/
object MatrixToConverter {
const val MATRIX_TO_URL_BASE = "https://matrix.to/#/"
/**
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
* To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
@ -35,14 +34,15 @@ object MatrixToConverter {
*/
fun convert(uri: Uri): Uri? {
val uriString = uri.toString()
val baseUrl = MatrixConfiguration.matrixToPermalinkBaseUrl
return when {
// URL is already a matrix.to
uriString.startsWith(MATRIX_TO_URL_BASE) -> uri
uriString.startsWith(baseUrl) -> uri
// Web or client url
SUPPORTED_PATHS.any { it in uriString } -> {
val path = SUPPORTED_PATHS.first { it in uriString }
Uri.parse(MATRIX_TO_URL_BASE + uriString.substringAfter(path))
Uri.parse(baseUrl + uriString.substringAfter(path))
}
// URL is not supported
else -> null

View File

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.permalink
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
object PermalinkBuilder {
private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.matrixToPermalinkBaseUrl).also {
var baseUrl = it
if (!baseUrl.endsWith("/")) {
baseUrl += "/"
}
if (!baseUrl.endsWith("/#/")) {
baseUrl += "/#/"
}
}
fun permalinkForRoomAlias(roomAlias: String): Result<String> {
return if (MatrixPatterns.isRoomAlias(roomAlias)) {
Result.success(permalinkForRoomAliasOrId(roomAlias))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomAlias)
}
}
fun permalinkForRoomId(roomId: RoomId): Result<String> {
return if (MatrixPatterns.isRoomId(roomId.value)) {
Result.success(permalinkForRoomAliasOrId(roomId.value))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomId)
}
}
private fun permalinkForRoomAliasOrId(value: String): String {
val id = escapeId(value)
return permalinkBaseUrl + id
}
private fun escapeId(value: String) = value.replace("/", "%2F")
}
sealed class PermalinkBuilderError : Throwable() {
object InvalidRoomAlias : PermalinkBuilderError()
object InvalidRoomId : PermalinkBuilderError()
}

View File

@ -27,8 +27,12 @@ interface MatrixRoom: Closeable {
val name: String?
val bestName: String
val displayName: String
val alias: String?
val alternativeAliases: List<String>
val topic: String?
val avatarUrl: String?
val members: List<RoomMember>
val isEncrypted: Boolean
fun syncUpdateFlow(): Flow<Long>

View File

@ -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.matrix.api.room
data class RoomMember(
val userId: String,
val displayName: String?,
val avatarUrl: String?,
val membership: RoomMembershipState,
val isNameAmbiguous: Boolean,
val powerLevel: Long,
val normalizedPowerLevel: Long
)
enum class RoomMembershipState {
BAN, INVITE, JOIN, KNOCK, LEAVE
}

View File

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
object RoomMemberMapper {
fun map(roomMember: RustRoomMember): RoomMember =
RoomMember(
roomMember.userId,
roomMember.displayName,
roomMember.avatarUrl,
mapMembership(roomMember.membership),
roomMember.isNameAmbiguous,
roomMember.powerLevel,
roomMember.normalizedPowerLevel,
)
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
when (membershipState) {
RustMembershipState.BAN -> RoomMembershipState.BAN
RustMembershipState.INVITE -> RoomMembershipState.INVITE
RustMembershipState.JOIN -> RoomMembershipState.JOIN
RustMembershipState.KNOCK -> RoomMembershipState.KNOCK
RustMembershipState.LEAVE -> RoomMembershipState.LEAVE
}
}

View File

@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
@ -94,6 +95,18 @@ class RustMatrixRoom(
return innerRoom.avatarUrl()
}
override val members: List<RoomMember>
get() = innerRoom.members().map(RoomMemberMapper::map)
override val isEncrypted: Boolean
get() = innerRoom.isEncrypted()
override val alias: String?
get() = innerRoom.canonicalAlias()
override val alternativeAliases: List<String>
get() = innerRoom.alternativeAliases()
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()

View File

@ -35,7 +35,9 @@ import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.PaginationOptions
import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
@ -145,7 +147,8 @@ class RustMatrixTimeline(
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
runCatching {
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, null)
val settings = RoomSubscription(requiredState = listOf(RequiredState(key = "m.room.canonical_alias", value = "")), timelineLimit = null)
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
listenerTokens += result.taskHandle
result.items
}

View File

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -33,6 +34,10 @@ class FakeMatrixRoom(
override val displayName: String = "",
override val topic: String? = null,
override val avatarUrl: String? = null,
override val members: List<RoomMember> = emptyList(),
override val isEncrypted: Boolean = false,
override val alias: String? = null,
override val alternativeAliases: List<String> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {

View File

@ -17,7 +17,6 @@
<string name="action_disable">"Disable"</string>
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_screenshot">"Edit screenshot"</string>
<string name="action_enable">"Enable"</string>
<string name="action_invite">"Invite"</string>
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
@ -39,6 +38,7 @@
<string name="action_save">"Save"</string>
<string name="action_search">"Search"</string>
<string name="action_send">"Send"</string>
<string name="action_share_link">"Share link"</string>
<string name="action_skip">"Skip"</string>
<string name="action_start">"Start"</string>
<string name="action_start_chat">"Start chat"</string>
@ -63,6 +63,7 @@
<string name="common_message_layout">"Message layout"</string>
<string name="common_message_removed">"Message removed"</string>
<string name="common_modern">"Modern"</string>
<string name="common_no_results">"No results"</string>
<string name="common_offline">"Offline"</string>
<string name="common_password">"Password"</string>
<string name="common_people">"People"</string>
@ -103,6 +104,7 @@
<string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string>
<string name="error_failed_loading_messages">"Failed loading messages"</string>
<string name="error_no_compatible_app_found">"No compatible app was found to handle this action."</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
@ -125,13 +127,13 @@
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_topic_title">"Topic"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string>
<string name="screen_room_member_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_room_member_details_block_user">"Block user"</string>
<string name="screen_room_member_details_unblock_alert_action">"Unblock"</string>
<string name="screen_room_member_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_room_member_details_unblock_user">"Unblock user"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>

View File

@ -124,7 +124,7 @@ data class MobileScreen(
* The screen shown when tapping the name of a room from the Room
* screen.
*/
RoomDetails,
RoomDetailss,
/**
* The screen that lists public rooms for you to discover.

View File

@ -57,6 +57,12 @@
"screen_roomlist_.*",
"session_verification_banner_.*"
]
},
{
"name": ":features:roomdetails:impl",
"includeRegex": [
"screen_room_details_.*"
]
}
]
}