Add an extension for room details sections (#7781)

* Add an extension for room details sections

Provide a no-op rendering hook for build-specific room details sections and shared room audio labels. Keep the default room details behavior unchanged.

* Cover the default room details extension

Verify that the no-op extension renders no content and leaves surrounding UI unchanged through recomposition. Cover its constructor and both generated render paths.

* Refine the room details extension and link the enterprise picker

Expose ColumnScope to contributed room details sections and use room-details string keys. Pin the enterprise submodule to the reviewed picker implementation so integrated builds include it behind the default-off flag.

* Make the room details extension a functional interface

Declare the single composable rendering contract as a fun interface to satisfy the Sonar check while preserving its named dependency injection binding.

* Update localazy strings for the audio section in room details

* Bump enterprise commit

---------

Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
Jenna Vassar
2026-09-30 07:18:13 -07:00
committed by GitHub
parent 439898a74c
commit 0f29e8e875
9 changed files with 113 additions and 2 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
id("kotlin-parcelize") id("kotlin-parcelize")
} }
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.api
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/** Optional room-details sections contributed by a build-specific implementation. */
fun interface RoomDetailsExtension {
@Composable
fun ColumnScope.Render(modifier: Modifier)
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.roomdetails.api.RoomDetailsExtension
import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class)
class NoopRoomDetailsExtension : RoomDetailsExtension {
@Composable
override fun ColumnScope.Render(modifier: Modifier) = Unit
}
@@ -24,6 +24,7 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.leaveroom.api.LeaveRoomRenderer import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.roomdetails.api.RoomDetailsExtension
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
@@ -46,6 +47,7 @@ class RoomDetailsNode(
private val room: BaseRoom, private val room: BaseRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val leaveRoomRenderer: LeaveRoomRenderer, private val leaveRoomRenderer: LeaveRoomRenderer,
private val roomDetailsExtension: RoomDetailsExtension,
) : Node(buildContext, plugins = plugins), RoomDetailsNavigator { ) : Node(buildContext, plugins = plugins), RoomDetailsNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateBack() fun navigateBack()
@@ -120,6 +122,7 @@ class RoomDetailsNode(
RoomDetailsView( RoomDetailsView(
state = state, state = state,
additionalSections = { with(roomDetailsExtension) { Render(Modifier) } },
modifier = modifier, modifier = modifier,
goBack = ::navigateUp, goBack = ::navigateUp,
onActionClick = ::onActionClick, onActionClick = ::onActionClick,
@@ -14,6 +14,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
@@ -123,6 +124,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit, onProfileClick: (UserId) -> Unit,
onReportRoomClick: () -> Unit, onReportRoomClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
additionalSections: @Composable ColumnScope.() -> Unit = {},
leaveRoomView: @Composable () -> Unit, leaveRoomView: @Composable () -> Unit,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -198,6 +200,8 @@ fun RoomDetailsView(
) )
} }
additionalSections()
PreferenceCategory { PreferenceCategory {
if (state.hasNewContent) { if (state.hasNewContent) {
ListItem( ListItem(
@@ -42,6 +42,9 @@
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string> <string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string> <string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_details_add_topic_title">"Add topic"</string> <string name="screen_room_details_add_topic_title">"Add topic"</string>
<string name="screen_room_details_audio_push_to_talk">"Push to talk"</string>
<string name="screen_room_details_audio_title">"Room Audio"</string>
<string name="screen_room_details_audio_video_calls">"Video calls"</string>
<string name="screen_room_details_badge_encrypted">"Encrypted"</string> <string name="screen_room_details_badge_encrypted">"Encrypted"</string>
<string name="screen_room_details_badge_not_encrypted">"Not encrypted"</string> <string name="screen_room_details_badge_not_encrypted">"Not encrypted"</string>
<string name="screen_room_details_badge_public">"Public room"</string> <string name="screen_room_details_badge_public">"Public room"</string>
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.roomdetails.impl
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class NoopRoomDetailsExtensionTest : RobolectricTest() {
@Test
fun `default extension renders no additional content`() = runAndroidComposeUiTest<ComponentActivity> {
val extension = NoopRoomDetailsExtension()
setContent { Column { with(extension) { Render(Modifier) } } }
onRoot().onChildren().assertCountEquals(0)
}
@Test
fun `default extension leaves surrounding content unchanged during updates`() = runAndroidComposeUiTest<ComponentActivity> {
val extension = NoopRoomDetailsExtension()
val text = mutableStateOf("Before")
setContent {
Column {
BasicText(text.value)
with(extension) { Render(Modifier) }
}
}
onNodeWithText("Before").assertExists()
onRoot().onChildren().assertCountEquals(1)
runOnIdle { text.value = "After" }
onNodeWithText("After").assertExists()
onRoot().onChildren().assertCountEquals(1)
}
}
@@ -11,16 +11,20 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@@ -41,6 +45,12 @@ import org.junit.Test
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
class RoomDetailsViewTest : RobolectricTest() { class RoomDetailsViewTest : RobolectricTest() {
@Test
fun `extension section is rendered`() = runAndroidComposeUiTest {
setRoomDetailView(additionalSections = { Text("Extra section") })
onNodeWithText("Extra section").assertExists()
}
@Test @Test
fun `click on back invokes expected callback`() = runAndroidComposeUiTest { fun `click on back invokes expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -381,6 +391,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomDetailView(
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(), onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: () -> Unit = EnsureNeverCalled(), onReportRoomClick: () -> Unit = EnsureNeverCalled(),
additionalSections: @Composable ColumnScope.() -> Unit = {},
) { ) {
setContent { setContent {
RoomDetailsView( RoomDetailsView(
@@ -402,6 +413,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomDetailView(
onProfileClick = onProfileClick, onProfileClick = onProfileClick,
onReportRoomClick = onReportRoomClick, onReportRoomClick = onReportRoomClick,
leaveRoomView = {}, leaveRoomView = {},
additionalSections = additionalSections,
) )
} }
} }