Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-26 11:39:34 +02:00
commit 1ce0324570
165 changed files with 3727 additions and 1085 deletions

View file

@ -42,7 +42,7 @@ jobs:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: mobile-dev-inc/action-maestro-cloud@v1.3.2 - uses: mobile-dev-inc/action-maestro-cloud@v1.3.3
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk app-file: app/build/outputs/apk/debug/app-universal-debug.apk

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
jobs: jobs:
check: check:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn
jobs: jobs:
tests: tests:

View file

@ -206,15 +206,14 @@ knit {
dependencies { dependencies {
allLibrariesImpl() allLibrariesImpl()
allServicesImpl() allServicesImpl()
allFeaturesImpl(rootDir) allFeaturesImpl(rootDir, logger)
implementation(projects.libraries.deeplink) implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests) implementation(projects.tests.uitests)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.appnav) implementation(projects.appnav)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
// https://developer.android.com/studio/write/java8-support#library-desugaring-versions coreLibraryDesugaring(libs.android.desugar)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(libs.appyx.core) implementation(libs.appyx.core)
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.androidx.core) implementation(libs.androidx.core)

View file

@ -36,7 +36,7 @@ dependencies {
implementation(libs.dagger) implementation(libs.dagger)
kapt(libs.dagger.compiler) kapt(libs.dagger.compiler)
allFeaturesApi(rootDir) allFeaturesApi(rootDir, logger)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)

View file

@ -135,7 +135,10 @@ class LoggedInFlowNode @AssistedInject constructor(
object RoomList : NavTarget object RoomList : NavTarget
@Parcelize @Parcelize
data class Room(val roomId: RoomId) : NavTarget data class Room(
val roomId: RoomId,
val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages
) : NavTarget
@Parcelize @Parcelize
object Settings : NavTarget object Settings : NavTarget
@ -176,6 +179,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onInvitesClicked() { override fun onInvitesClicked() {
backstack.push(NavTarget.InviteList) backstack.push(NavTarget.InviteList)
} }
override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
}
} }
roomListEntryPoint roomListEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
@ -193,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} else { } else {
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
val inputs = RoomFlowNode.Inputs(room) val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
} }
} }

View file

@ -59,7 +59,7 @@ class RoomFlowNode @AssistedInject constructor(
roomMembershipObserver: RoomMembershipObserver, roomMembershipObserver: RoomMembershipObserver,
) : BackstackNode<RoomFlowNode.NavTarget>( ) : BackstackNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -73,6 +73,7 @@ class RoomFlowNode @AssistedInject constructor(
data class Inputs( data class Inputs(
val room: MatrixRoom, val room: MatrixRoom,
val initialElement: NavTarget = NavTarget.Messages,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -98,6 +99,7 @@ class RoomFlowNode @AssistedInject constructor(
navigateUp() navigateUp()
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
inputs<Inputs>()
} }
private fun fetchRoomMembers() = lifecycleScope.launch { private fun fetchRoomMembers() = lifecycleScope.launch {

View file

@ -59,7 +59,7 @@ allprojects {
config = files("$rootDir/tools/detekt/detekt.yml") config = files("$rootDir/tools/detekt/detekt.yml")
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.6") detektPlugins("io.nlopez.compose.rules:detekt:0.1.7")
} }
// KtLint // KtLint

1
changelog.d/427.feature Normal file
View file

@ -0,0 +1 @@
Room list contextual menu

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string> <string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Personen einladen"</string>
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string> <string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string> <string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string> <string name="screen_create_room_room_name_label">"Raumname"</string>

View file

@ -161,12 +161,12 @@ fun InviteListContent(
@Preview @Preview
@Composable @Composable
internal fun RoomListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = internal fun InviteListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
internal fun RoomListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = internal fun InviteListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable

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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.leaveroom.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api)
ksp(libs.showkase.processor)
}

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.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveRoomEvent {
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
object HideConfirmation : LeaveRoomEvent
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
object HideError : LeaveRoomEvent
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.leaveroom.api
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
interface LeaveRoomPresenter : Presenter<LeaveRoomState> {
@Composable
override fun present(): LeaveRoomState
}

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.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
data class LeaveRoomState(
val confirmation: Confirmation = Confirmation.Hidden,
val progress: Progress = Progress.Hidden,
val error: Error = Error.Hidden,
val eventSink: (LeaveRoomEvent) -> Unit = {},
) {
sealed interface Confirmation {
object Hidden : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
}
sealed interface Progress {
object Hidden : Progress
object Shown : Progress
}
sealed interface Error {
object Hidden : Error
object Shown : Error
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.leaveroom.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState>
get() = sequenceOf(
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown,
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")

View file

@ -0,0 +1,131 @@
/*
* 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.leaveroom.api
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun LeaveRoomView(
state: LeaveRoomState
) {
LeaveRoomConfirmationDialog(state)
LeaveRoomProgressDialog(state)
LeaveRoomErrorDialog(state)
}
@Composable
private fun LeaveRoomConfirmationDialog(
state: LeaveRoomState,
) {
when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
}
}
@Composable
private fun LeaveRoomConfirmationDialog(
@StringRes text: Int,
roomId: RoomId,
eventSink: (LeaveRoomEvent) -> Unit,
) {
ConfirmationDialog(
content = stringResource(text),
submitText = stringResource(R.string.action_leave),
onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
)
}
@Composable
private fun LeaveRoomProgressDialog(
state: LeaveRoomState,
) {
when (state.progress) {
is LeaveRoomState.Progress.Hidden -> {}
is LeaveRoomState.Progress.Shown -> ProgressDialog(
text = stringResource(StringR.string.common_leaving_room),
)
}
}
@Composable
private fun LeaveRoomErrorDialog(
state: LeaveRoomState,
) {
when (state.error) {
is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(StringR.string.error_unknown),
onDismiss = { state.eventSink(LeaveRoomEvent.HideError) }
)
}
}
@Preview
@Composable
internal fun LeaveRoomViewLightPreview(
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
) = ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun LeaveRoomViewDarkPreview(
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
) = ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LeaveRoomState) {
Box(
modifier = Modifier.size(300.dp, 300.dp),
propagateMinConstraints = true,
) {
LeaveRoomView(state = state)
}
}

View file

@ -0,0 +1,44 @@
/*
* 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)
}
android {
namespace = "io.element.android.features.leaveroom.fake"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.leaveroom.fake
import androidx.compose.runtime.Composable
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import javax.inject.Inject
class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
val events = mutableListOf<LeaveRoomEvent>()
private fun handleEvent(event: LeaveRoomEvent) {
events += event
}
private var state = LeaveRoomState(eventSink = ::handleEvent)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}
fun givenState(state: LeaveRoomState) {
this.state = state
}
@Composable
override fun present(): LeaveRoomState = state
}

View file

@ -0,0 +1,30 @@
/*
* 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.leaveroom.fake
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.libraries.di.SessionScope
@Module
@ContributesTo(SessionScope::class)
interface LeaveRoomPresenterFakeModule {
@Binds
fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter
}

View file

@ -0,0 +1,46 @@
/*
* 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)
}
android {
namespace = "io.element.android.features.leaveroom.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -0,0 +1,127 @@
/*
* 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.leaveroom.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class LeaveRoomPresenterImpl @Inject constructor(
private val client: MatrixClient,
private val roomMembershipObserver: RoomMembershipObserver,
private val dispatchers: CoroutineDispatchers,
) : LeaveRoomPresenter {
@Composable
override fun present(): LeaveRoomState {
val scope = rememberCoroutineScope()
val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) }
val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) }
val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) }
return LeaveRoomState(
confirmation = confirmation.value,
progress = progress.value,
error = error.value,
) { event ->
when (event) {
is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) {
showLeaveRoomAlert(
matrixClient = client,
roomId = event.roomId,
confirmation = confirmation,
)
}
is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
client.leaveRoom(
roomId = event.roomId,
roomMembershipObserver = roomMembershipObserver,
confirmation = confirmation,
progress = progress,
error = error,
)
}
is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden
}
}
}
}
private suspend fun showLeaveRoomAlert(
matrixClient: MatrixClient,
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
) {
matrixClient.getRoom(roomId)?.use { room ->
confirmation.value = when {
!room.isPublic -> PrivateRoom(roomId)
(room.memberCount() as? Async.Success<Int>)?.state == 1 -> LastUserInRoom(roomId)
else -> Generic(roomId)
}
}
}
private suspend fun MatrixClient.leaveRoom(
roomId: RoomId,
roomMembershipObserver: RoomMembershipObserver,
confirmation: MutableState<LeaveRoomState.Confirmation>,
progress: MutableState<LeaveRoomState.Progress>,
error: MutableState<LeaveRoomState.Error>,
) {
confirmation.value = LeaveRoomState.Confirmation.Hidden
progress.value = LeaveRoomState.Progress.Shown
getRoom(roomId)?.use { room ->
room.leave().onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
Timber.e(it, "Error while leaving room ${room.name} - ${room.roomId}")
error.value = LeaveRoomState.Error.Shown
}
}
progress.value = LeaveRoomState.Progress.Hidden
}
private suspend fun MatrixRoom.memberCount(): Async<Int> = membersStateFlow.first().let { membersState ->
when (membersState) {
MatrixRoomMembersState.Unknown -> Async.Uninitialized
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
}
}

View file

@ -0,0 +1,30 @@
/*
* 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.leaveroom.impl
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.libraries.di.SessionScope
@Module
@ContributesTo(SessionScope::class)
interface LeaveRoomPresenterImplModule {
@Binds
fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter
}

View file

@ -0,0 +1,238 @@
/*
* 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.leaveroom.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveRoomPresenterImplTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden)
assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
}
}
@Test
fun `present - show generic confirmation`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom()
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID))
}
}
@Test
fun `present - show private room confirmation`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(isPublic = false),
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID))
}
}
@Test
fun `present - show last user in room confirmation`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
RoomMember(
userId = UserId(value = "@aUserId:aDomain"),
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false
)
)
)
)
},
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID))
}
}
@Test
fun `present - leaving a room leaves the room`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(),
)
},
roomMembershipObserver = roomMembershipObserver
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
// Membership observer should receive a 'left room' change
assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show error if leave room fails`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply {
givenLeaveRoomError(RuntimeException("Blimey!"))
},
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
skipItems(1) // Skip show progress state
val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show progress indicator while leaving a room`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(),
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
val progressState = awaitItem()
assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown)
val finalState = awaitItem()
assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
}
}
@Test
fun `present - hide error hides the error`() = runTest {
val presenter = createPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply {
givenLeaveRoomError(RuntimeException("Blimey!"))
},
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
skipItems(1) // Skip show progress state
val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
skipItems(1) // Skip hide progress state
errorState.eventSink(LeaveRoomEvent.HideError)
val hiddenErrorState = awaitItem()
assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden)
}
}
}
private fun TestScope.createPresenter(
client: MatrixClient = FakeMatrixClient(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): LeaveRoomPresenter = LeaveRoomPresenterImpl(
client = client,
roomMembershipObserver = roomMembershipObserver,
dispatchers = testCoroutineDispatchers(testScheduler, false),
)

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit kein Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Sie können nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Ihr Homeserver-Administrator muss dies konfigurieren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string> <string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>
<string name="screen_login_title">"Willkommen zurück!"</string> <string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_password_hint">"Passwort"</string> <string name="screen_login_password_hint">"Passwort"</string>

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.textcomposer) implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)

View file

@ -19,9 +19,9 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemContent
import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -46,7 +46,7 @@ fun aMessagesState() = MessagesState(
mode = MessageComposerMode.Normal("Hello"), mode = MessageComposerMode.Normal("Hello"),
), ),
timelineState = aTimelineState().copy( timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemContent()), timelineItems = aTimelineItemList(aTimelineItemTextContent()),
), ),
actionListState = anActionListState(), actionListState = anActionListState(),
hasNetworkConnection = true, hasNetworkConnection = true,

View file

@ -39,7 +39,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.LocalLibrary
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
@ -69,6 +68,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
@ -88,7 +88,6 @@ import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@ -154,6 +153,11 @@ fun MessagesView(
} }
} }
fun onExpandGroupClick(event: TimelineItem.GroupedEvents) {
Timber.v("onExpandGroupClick= ${event.id}")
state.timelineState.eventSink(TimelineEvents.ToggleExpandGroup(event))
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event)) state.eventSink(MessagesEvents.HandleAction(action, event))
} }
@ -203,7 +207,8 @@ fun MessagesView(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked, onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked onMessageLongClicked = ::onMessageLongClicked,
onExpandGroupClick = ::onExpandGroupClick,
) )
}, },
snackbarHost = { snackbarHost = {
@ -242,6 +247,7 @@ fun MessagesViewContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -255,7 +261,8 @@ fun MessagesViewContent(
state = state.timelineState, state = state.timelineState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked onMessageLongClicked = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
) )
} }
MessageComposerView( MessageComposerView(

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -53,20 +54,25 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
) )
} }
fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch { private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val actions = val actions =
if (timelineItem.content is TimelineItemRedactedContent) { when (timelineItem.content) {
emptyList() is TimelineItemRedactedContent,
} else { is TimelineItemStateContent -> {
mutableListOf( // TODO Add Share action (also) here, and developer options
TimelineItemAction.Reply, emptyList()
TimelineItemAction.Forward, }
TimelineItemAction.Copy, else -> {
).also { mutableListOf(
if (timelineItem.isMine) { TimelineItemAction.Reply,
it.add(TimelineItemAction.Edit) TimelineItemAction.Forward,
it.add(TimelineItemAction.Redact) TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
}
} }
} }
} }

View file

@ -16,9 +16,11 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
sealed interface TimelineEvents { sealed interface TimelineEvents {
object LoadMore : TimelineEvents object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class ToggleExpandGroup(val event: TimelineItem.GroupedEvents) : TimelineEvents
} }

View file

@ -17,15 +17,18 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -42,6 +45,7 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor( class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemGrouper: TimelineItemGrouper,
room: MatrixRoom, room: MatrixRoom,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@ -53,6 +57,8 @@ class TimelinePresenter @Inject constructor(
val highlightedEventId: MutableState<EventId?> = rememberSaveable { val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
val expandedGroups = remember { mutableStateMapOf<String, Boolean>() }
val timelineItems = timelineItemsFactory val timelineItems = timelineItemsFactory
.flow() .flow()
.collectAsState() .collectAsState()
@ -65,6 +71,9 @@ class TimelinePresenter @Inject constructor(
when (event) { when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value) TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.ToggleExpandGroup -> {
expandedGroups[event.event.identifier()] = expandedGroups[event.event.identifier()].orFalse().not()
}
} }
} }
@ -83,7 +92,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState( return TimelineState(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value, paginationState = paginationState.value,
timelineItems = timelineItems.value.toImmutableList(), timelineItems = timelineItemGrouper.group(timelineItems.value, expandedGroups).toImmutableList(),
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -21,7 +21,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -55,6 +56,12 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content, content = content,
groupPosition = TimelineItemGroupPosition.First groupPosition = TimelineItemGroupPosition.First
), ),
// A state event on top of it
aTimelineItemEvent(
isMine = false,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
// 3 items (First Middle Last) with isMine = true // 3 items (First Middle Last) with isMine = true
aTimelineItemEvent( aTimelineItemEvent(
isMine = true, isMine = true,
@ -71,12 +78,18 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content, content = content,
groupPosition = TimelineItemGroupPosition.First groupPosition = TimelineItemGroupPosition.First
), ),
// A state event on top of it
aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
) )
} }
internal fun aTimelineItemEvent( internal fun aTimelineItemEvent(
isMine: Boolean = false, isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
): TimelineItem.Event { ): TimelineItem.Event {
val randomId = "\$" + Random.nextInt().toString() val randomId = "\$" + Random.nextInt().toString()
@ -96,10 +109,3 @@ internal fun aTimelineItemEvent(
groupPosition = groupPosition, groupPosition = groupPosition,
) )
} }
internal fun aTimelineItemContent(): TimelineItemEventContent {
return TimelineItemTextContent(
body = "Text",
htmlDocument = null
)
}

View file

@ -16,8 +16,8 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -49,19 +49,24 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -81,6 +86,7 @@ fun TimelineView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -92,8 +98,6 @@ fun TimelineView(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = lazyListState, state = lazyListState,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Bottom,
reverseLayout = true reverseLayout = true
) { ) {
itemsIndexed( itemsIndexed(
@ -103,9 +107,10 @@ fun TimelineView(
) { index, timelineItem -> ) { index, timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked onLongClick = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
onReachedLoadMore() onReachedLoadMore()
@ -124,16 +129,20 @@ fun TimelineView(
@Composable @Composable
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
isHighlighted: Boolean, highlightedItem: String?,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit,
modifier: Modifier = Modifier
) { ) {
when (timelineItem) { when (timelineItem) {
is TimelineItem.Virtual -> TimelineItemVirtualRow( is TimelineItem.Virtual -> {
virtual = timelineItem TimelineItemVirtualRow(
) virtual = timelineItem,
modifier = modifier,
)
}
is TimelineItem.Event -> { is TimelineItem.Event -> {
fun onClick() { fun onClick() {
onClick(timelineItem) onClick(timelineItem)
} }
@ -142,12 +151,54 @@ fun TimelineItemRow(
onLongClick(timelineItem) onLongClick(timelineItem)
} }
TimelineItemEventRow( if (timelineItem.content is TimelineItemStateContent) {
event = timelineItem, TimelineItemStateEventRow(
isHighlighted = isHighlighted, event = timelineItem,
onClick = ::onClick, isHighlighted = highlightedItem == timelineItem.identifier(),
onLongClick = ::onLongClick onClick = ::onClick,
) onLongClick = ::onLongClick,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
fun onExpandGroupClick() {
onExpandGroupClick(timelineItem)
}
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = timelineItem.expanded,
isHighlighted = !timelineItem.expanded && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick,
)
if (timelineItem.expanded) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
onClick = onClick,
onLongClick = onLongClick,
onExpandGroupClick = {}
)
}
}
}
}
} }
} }
} }
@ -236,6 +287,42 @@ fun TimelineItemEventRow(
} }
} }
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = contentModifier
)
}
}
}
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, sender: String,

View file

@ -84,6 +84,7 @@ fun MessageEventBubble(
fun Modifier.offsetForItem(): Modifier { fun Modifier.offsetForItem(): Modifier {
return if (state.isMine) { return if (state.isMine) {
// FIXME setting y offset to -12.dp can overlap a state event displayed above.
offset(y = -(12.dp)) offset(y = -(12.dp))
} else { } else {
offset(x = 20.dp, y = -(12.dp)) offset(x = 20.dp, y = -(12.dp))

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
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.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageStateEventContainer(
isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
val shape = RoundedCornerShape(CORNER_RADIUS)
Surface(
modifier = modifier
.widthIn(min = 80.dp)
.clip(shape)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundColor,
shape = shape,
content = content
)
}
@Preview
@Composable
internal fun MessageStateEventContainerLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun MessageStateEventContainerDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
MessageStateEventContainer(
isHighlighted = false,
interactionSource = MutableInteractionSource(),
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
MessageStateEventContainer(
isHighlighted = true,
interactionSource = MutableInteractionSource(),
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
}
}

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -74,5 +75,9 @@ fun TimelineItemEventContentView(
content = content, content = content,
modifier = modifier.defaultContentPadding() modifier = modifier.defaultContentPadding()
) )
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier
)
} }
} }

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemStateView(
content: TimelineItemStateContent,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier,
color = MaterialTheme.colorScheme.secondary,
fontSize = 13.sp,
text = content.body,
textAlign = TextAlign.Center,
)
}
@Preview
@Composable
internal fun TimelineItemStateViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemStateViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineItemStateView(
content = aTimelineItemStateEventContent(),
)
}

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.group
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
private val CORNER_RADIUS = 8.dp
@Composable
fun GroupHeaderView(
text: String,
isExpanded: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(
modifier = modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.clip(shape)
.clickable(onClick = onClick),
color = backgroundColor,
shape = shape,
) {
Row(
modifier = Modifier
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
color = MaterialTheme.colorScheme.secondary,
fontSize = 13.sp
)
val icon = if (isExpanded) {
Icons.Default.ExpandLess
} else {
Icons.Default.ExpandMore
}
Icon(icon, "", tint = MaterialTheme.colorScheme.secondary)
}
}
}
}
@Preview
@Composable
fun GroupHeaderViewLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun GroupHeaderViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
GroupHeaderView(
text = "8 room changes (expanded)",
isExpanded = true,
isHighlighted = false,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (not expanded)",
isExpanded = false,
isHighlighted = false,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (expanded/h)",
isExpanded = true,
isHighlighted = true,
onClick = {}
)
GroupHeaderView(
text = "8 room changes (not expanded/h)",
isExpanded = false,
isHighlighted = true,
onClick = {}
)
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@ -26,7 +27,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import javax.inject.Inject import javax.inject.Inject
@ -43,15 +43,15 @@ class TimelineItemContentFactory @Inject constructor(
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory
) { ) {
fun create(itemContent: EventContent): TimelineItemEventContent { fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return when (itemContent) { return when (val itemContent = eventTimelineItem.content) {
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> messageFactory.create(itemContent) is MessageContent -> messageFactory.create(itemContent)
is ProfileChangeContent -> profileChangeFactory.create(itemContent) is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent) is RedactedContent -> redactedMessageFactory.create(itemContent)
is RoomMembershipContent -> roomMembershipFactory.create(itemContent) is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(itemContent) is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent) is StickerContent -> stickerFactory.create(itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentProfileChangeFactory @Inject constructor() { class TimelineItemContentProfileChangeFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: ProfileChangeContent): TimelineItemEventContent { fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return TimelineItemUnknownContent val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemProfileChangeContent(text.orEmpty().toString())
} }
} }

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentRoomMembershipFactory @Inject constructor() { class TimelineItemContentRoomMembershipFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: RoomMembershipContent): TimelineItemEventContent { fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return TimelineItemUnknownContent val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemRoomMembershipContent(text.orEmpty().toString())
} }
} }

View file

@ -17,13 +17,18 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentStateFactory @Inject constructor() { class TimelineItemContentStateFactory @Inject constructor(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(content: StateContent): TimelineItemEventContent { fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return TimelineItemUnknownContent val text = timelineEventFormatter.format(eventTimelineItem)
return TimelineItemStateEventContent(text.orEmpty().toString())
} }
} }

View file

@ -67,7 +67,7 @@ class TimelineItemEventFactory @Inject constructor(
senderId = currentSender, senderId = currentSender,
senderDisplayName = senderDisplayName, senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData, senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event.content), content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn, isMine = currentTimelineItem.event.isOwn,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState() reactionsState = currentTimelineItem.computeReactionsState()

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class TimelineItemGrouper @Inject constructor() {
/**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/
fun group(from: List<TimelineItem>, expandedGroups: Map<String, Boolean>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>()
val currentGroup = mutableListOf<TimelineItem.Event>()
from.forEach { timelineItem ->
if (timelineItem is TimelineItem.Event && timelineItem.canBeGrouped()) {
currentGroup.add(0, timelineItem)
} else {
// timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup, expandedGroups)
currentGroup.clear()
}
result.add(timelineItem)
}
}
if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup, expandedGroups)
}
return result
}
private fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
TimelineItemRedactedContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
}
/**
* Will add a group if there is more than 1 item, else add the item to the list.
*/
private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>,
expandedGroups: Map<String, Boolean>,
) {
if (group.size == 1) {
// Do not create a group with just 1 item, just add the item to the result
add(group.first())
} else {
add(
TimelineItem.GroupedEvents(
expanded = expandedGroups[group.first().id + "_group"].orFalse(),
events = group.toImmutableList()
)
)
}
}

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
sealed interface TimelineItem { sealed interface TimelineItem {
@ -29,11 +30,13 @@ sealed interface TimelineItem {
fun identifier(): String = when (this) { fun identifier(): String = when (this) {
is Event -> id is Event -> id
is Virtual -> id is Virtual -> id
is GroupedEvents -> id
} }
fun contentType(): String = when (this) { fun contentType(): String = when (this) {
is Event -> content.type is Event -> content.type
is Virtual -> model.type is Virtual -> model.type
is GroupedEvents -> "groupedEvent"
} }
@Immutable @Immutable
@ -60,4 +63,13 @@ sealed interface TimelineItem {
val safeSenderName: String = senderDisplayName ?: senderId.value val safeSenderName: String = senderDisplayName ?: senderId.value
} }
@Immutable
data class GroupedEvents(
val expanded: Boolean,
val events: ImmutableList<Event>,
) : TimelineItem {
// use first id with a suffix
val id = events.first().id + "_group"
}
} }

View file

@ -68,3 +68,7 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
) )
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent(
body = "A state event",
)

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
package io.element.android.features.messages.impl.timeline.model.event
data class TimelineItemProfileChangeContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemProfileChangeContent"
}

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
package io.element.android.features.messages.impl.timeline.model.event
data class TimelineItemRoomMembershipContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemRoomMembershipContent"
}

View file

@ -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.features.messages.impl.timeline.model.event
sealed interface TimelineItemStateContent : TimelineItemEventContent {
val body: String
}

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
package io.element.android.features.messages.impl.timeline.model.event
data class TimelineItemStateEventContent(
override val body: String,
) : TimelineItemStateContent {
override val type: String = "TimelineItemStateEventContent"
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d cambio en la sala"</item>
<item quantity="other">"%1$d cambios en la sala"</item>
</plurals>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d modifica alla stanza"</item>
<item quantity="other">"%1$d modifiche alla stanza"</item>
</plurals>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d schimbare a camerii"</item>
<item quantity="few">"%1$d schimbări ale camerei"</item>
<item quantity="other">"%1$d schimbări ale camerei"</item>
</plurals>
</resources>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Camera"</string> <string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string> <string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
<string name="screen_room_attachment_source_camera_video">"Record a video"</string> <string name="screen_room_attachment_source_camera_video">"Record a video"</string>

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -141,6 +142,7 @@ class MessagesPresenterTest {
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = matrixRoom, room = matrixRoom,
) )
val actionListPresenter = ActionListPresenter() val actionListPresenter = ActionListPresenter()

View file

@ -31,26 +31,39 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
internal fun aTimelineItemsFactory() = TimelineItemsFactory( internal fun aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(), val timelineEventFormatter = aTimelineEventFormatter()
eventItemFactory = TimelineItemEventFactory( return TimelineItemsFactory(
TimelineItemContentFactory( dispatchers = testCoroutineDispatchers(),
messageFactory = TimelineItemContentMessageFactory(), eventItemFactory = TimelineItemEventFactory(
redactedMessageFactory = TimelineItemContentRedactedFactory(), TimelineItemContentFactory(
stickerFactory = TimelineItemContentStickerFactory(), messageFactory = TimelineItemContentMessageFactory(),
utdFactory = TimelineItemContentUTDFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(), stickerFactory = TimelineItemContentStickerFactory(),
profileChangeFactory = TimelineItemContentProfileChangeFactory(), utdFactory = TimelineItemContentUTDFactory(),
stateFactory = TimelineItemContentStateFactory(), roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
) failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
), failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
virtualItemFactory = TimelineItemVirtualFactory( )
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
), ),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
),
)
) )
) }
internal fun aTimelineEventFormatter(): TimelineEventFormatter {
return object : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence {
return ""
}
}
}

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.textcomposer package io.element.android.features.messages.textcomposer
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
@ -21,11 +23,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
@ -52,6 +54,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File

View file

@ -23,8 +23,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -33,6 +38,7 @@ class TimelinePresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -49,6 +55,7 @@ class TimelinePresenterTest {
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -71,6 +78,7 @@ class TimelinePresenterTest {
fun `present - set highlighted event`() = runTest { fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -87,4 +95,37 @@ class TimelinePresenterTest {
assertThat(withoutHighlightedState.highlightedEventId).isNull() assertThat(withoutHighlightedState.highlightedEventId).isNull()
} }
} }
@Test
fun `present - expand and collapse grouped events`() = runTest {
val fakeTimeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
)
)
val fakeRoom = FakeMatrixRoom(matrixTimeline = fakeTimeline)
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = fakeRoom,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
fakeTimeline.updateTimelineItems { it }
val loadedState = awaitItem()
val group1 = loadedState.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group1.expanded).isFalse()
loadedState.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group1))
val withExpandedGroup = awaitItem()
val group2 = withExpandedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group2.expanded).isTrue()
withExpandedGroup.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group2))
val withCollapsedGroup = awaitItem()
val group3 = withCollapsedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group3.expanded).isFalse()
}
}
} }

View file

@ -0,0 +1,170 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.timeline.groups
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value,
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList())
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@Test
fun `test empty`() {
val result = sut.group(emptyList(), emptyMap())
assertThat(result).isEmpty()
}
@Test
fun `test non groupables`() {
val result = sut.group(
listOf(
aNonGroupableItem,
aNonGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
aNonGroupableItem,
aNonGroupableItem,
)
)
}
@Test
fun `test groupables and ensure reordering`() {
val result = sut.group(
listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
).toImmutableList()
),
)
)
}
@Test
fun `test groupables expanded`() {
val result = sut.group(
listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
),
mapOf("${AN_EVENT_ID_2.value}_group" to true)
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = true,
events = listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
).toImmutableList()
),
)
)
}
@Test
fun `test 1 groupable, not group must be created`() {
val listsToTest = listOf(
listOf(aGroupableItem),
listOf(aGroupableItem, aNonGroupableItem),
listOf(aGroupableItem, aNonGroupableItemNoEvent),
listOf(aNonGroupableItem, aGroupableItem),
listOf(aNonGroupableItemNoEvent, aGroupableItem),
listOf(aNonGroupableItem, aGroupableItem, aNonGroupableItem),
listOf(aNonGroupableItemNoEvent, aGroupableItem, aNonGroupableItemNoEvent),
listOf(aGroupableItem, aNonGroupableItem, aGroupableItem),
listOf(aGroupableItem, aNonGroupableItemNoEvent, aGroupableItem),
listOf(aNonGroupableItem),
listOf(aNonGroupableItemNoEvent),
)
listsToTest.forEach { listToTest ->
val result = sut.group(listToTest, emptyMap())
assertThat(result).isEqualTo(listToTest)
}
}
@Test
fun `test 3 blocks`() {
val result = sut.group(
listOf(
aGroupableItem,
aGroupableItem,
aNonGroupableItem,
aGroupableItem,
aGroupableItem,
aGroupableItem,
),
emptyMap()
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem,
).toImmutableList()
),
aNonGroupableItem,
TimelineItem.GroupedEvents(
expanded = false,
events = listOf(
aGroupableItem,
aGroupableItem,
aGroupableItem,
).toImmutableList()
)
)
)
}
}

View file

@ -31,10 +31,21 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class RageshakeDetectionPresenterTest { class RageshakeDetectionPresenterTest {
companion object {
private lateinit var aBitmap: Bitmap
@BeforeClass
@JvmStatic
fun initBitmap() {
aBitmap = mockk()
}
}
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
@ -85,7 +96,7 @@ class RageshakeDetectionPresenterTest {
} }
@Test @Test
fun `present - screenshot with success then dismiss`() = runTest(timeout = 30.seconds) { fun `present - screenshot with success then dismiss`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true) val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
@ -108,7 +119,7 @@ class RageshakeDetectionPresenterTest {
rageshake.triggerPhoneRageshake() rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue() assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke( initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap))
) )
assertThat(awaitItem().showDialog).isTrue() assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss)
@ -153,7 +164,7 @@ class RageshakeDetectionPresenterTest {
} }
@Test @Test
fun `present - screenshot then disable`() = runTest(timeout = 1.seconds) { fun `present - screenshot then disable`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true) val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
@ -176,7 +187,7 @@ class RageshakeDetectionPresenterTest {
rageshake.triggerPhoneRageshake() rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue() assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke( initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap))
) )
assertThat(awaitItem().showDialog).isTrue() assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) initialState.eventSink.invoke(RageshakeDetectionEvents.Disable)
@ -187,5 +198,3 @@ class RageshakeDetectionPresenterTest {
} }
} }
private fun aBitmap(): Bitmap = mockk()

View file

@ -45,6 +45,7 @@ dependencies {
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.features.leaveroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -54,6 +55,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -17,7 +17,5 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent { sealed interface RoomDetailsEvent {
data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent object LeaveRoom : RoomDetailsEvent
object ClearLeaveRoomWarning : RoomDetailsEvent
object ClearError : RoomDetailsEvent
} }

View file

@ -18,44 +18,33 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: LeaveRoomPresenter,
) : Presenter<RoomDetailsState> { ) : Presenter<RoomDetailsState> {
@Composable @Composable
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope() val leaveRoomState = leaveRoomPresenter.present()
val leaveRoomWarning = remember {
mutableStateOf<LeaveRoomWarning?>(null)
}
val error = remember {
mutableStateOf<RoomDetailsError?>(null)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
room.updateMembers() room.updateMembers()
} }
@ -69,16 +58,8 @@ class RoomDetailsPresenter @Inject constructor(
fun handleEvents(event: RoomDetailsEvent) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
is RoomDetailsEvent.LeaveRoom -> { is RoomDetailsEvent.LeaveRoom ->
coroutineScope.leaveRoom( leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
needsConfirmation = event.needsConfirmation,
memberCount = memberCount,
leaveRoomWarning = leaveRoomWarning,
error = error,
)
}
RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error.value = null
} }
} }
@ -93,10 +74,9 @@ class RoomDetailsPresenter @Inject constructor(
memberCount = memberCount, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
canInvite = canInvite, canInvite = canInvite,
displayLeaveRoomWarning = leaveRoomWarning.value,
error = error.value,
roomType = roomType.value, roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -141,27 +121,4 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
} }
private fun CoroutineScope.leaveRoom(
needsConfirmation: Boolean,
memberCount: Async<Int>,
leaveRoomWarning: MutableState<LeaveRoomWarning?>,
error: MutableState<RoomDetailsError?>,
) = launch(coroutineDispatchers.io) {
if (needsConfirmation) {
leaveRoomWarning.value = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
} else {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error.value = RoomDetailsError.AlertGeneric
}
leaveRoomWarning.value = null
}
}
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -28,11 +29,10 @@ data class RoomDetailsState(
val roomTopic: String?, val roomTopic: String?,
val memberCount: Async<Int>, val memberCount: Async<Int>,
val isEncrypted: Boolean, val isEncrypted: Boolean,
val displayLeaveRoomWarning: LeaveRoomWarning?,
val error: RoomDetailsError?,
val roomType: RoomDetailsType, val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?, val roomMemberDetailsState: RoomMemberDetailsState?,
val canInvite: Boolean, val canInvite: Boolean,
val leaveRoomState: LeaveRoomState,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )
@ -40,23 +40,3 @@ sealed interface RoomDetailsType {
object Room : RoomDetailsType object Room : RoomDetailsType
data class Dm(val roomMember: RoomMember) : RoomDetailsType data class Dm(val roomMember: RoomMember) : RoomDetailsType
} }
sealed class LeaveRoomWarning {
object Generic : LeaveRoomWarning()
object PrivateRoom : LeaveRoomWarning()
object LastUserInRoom : LeaveRoomWarning()
companion object {
fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async<Int>): LeaveRoomWarning {
return when {
!isPublic -> PrivateRoom
(memberCount as? Async.Success<Int>)?.state == 1 -> LastUserInRoom
else -> Generic
}
}
}
}
sealed interface RoomDetailsError {
object AlertGeneric : RoomDetailsError
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -70,11 +71,10 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAI iki/Marketing...", "|| MAI iki/Marketing...",
memberCount = Async.Success(32), memberCount = Async.Success(32),
isEncrypted = true, isEncrypted = true,
displayLeaveRoomWarning = null,
error = null,
canInvite = false, canInvite = false,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null, roomMemberDetailsState = null,
leaveRoomState = LeaveRoomState(),
eventSink = {} eventSink = {}
) )

View file

@ -47,6 +47,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.architecture.isLoading
@ -56,8 +57,6 @@ 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.MainActionButton import io.element.android.libraries.designsystem.components.button.MainActionButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -68,7 +67,6 @@ 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -97,6 +95,8 @@ fun RoomDetailsView(
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
LeaveRoomView(state = state.leaveRoomState)
when (state.roomType) { when (state.roomType) {
RoomDetailsType.Room -> { RoomDetailsType.Room -> {
RoomHeaderSection( RoomHeaderSection(
@ -145,23 +145,8 @@ fun RoomDetailsView(
} }
OtherActionsSection(onLeaveRoom = { OtherActionsSection(onLeaveRoom = {
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) state.eventSink(RoomDetailsEvent.LeaveRoom)
}) })
if (state.displayLeaveRoomWarning != null) {
ConfirmLeaveRoomDialog(
leaveRoomWarning = state.displayLeaveRoomWarning,
onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) }
)
}
if (state.error != null) {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) }
)
}
} }
} }
} }
@ -260,27 +245,6 @@ internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = M
} }
} }
@Composable
internal fun ConfirmLeaveRoomDialog(
leaveRoomWarning: LeaveRoomWarning,
onConfirmLeave: () -> Unit,
onDismiss: () -> Unit
) {
val content = stringResource(
when (leaveRoomWarning) {
LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle
LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle
LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle
}
)
ConfirmationDialog(
content = content,
submitText = stringResource(StringR.string.action_leave),
onSubmitClicked = onConfirmLeave,
onDismiss = onDismiss,
)
}
@LargeHeightPreview @LargeHeightPreview
@Composable @Composable
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =

View file

@ -4,11 +4,13 @@
<item quantity="one">"1 Person"</item> <item quantity="one">"1 Person"</item>
<item quantity="other">"%1$d Personen"</item> <item quantity="other">"%1$d Personen"</item>
</plurals> </plurals>
<string name="screen_room_details_already_invited">"Bereits eingeladen"</string>
<string name="screen_room_details_share_room_title">"Raum teilen"</string> <string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_dm_details_block_alert_action">"Blockieren"</string> <string name="screen_dm_details_block_alert_action">"Blockieren"</string>
<string name="screen_dm_details_block_user">"Nutzer blockieren"</string> <string name="screen_dm_details_block_user">"Nutzer blockieren"</string>
<string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string> <string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string> <string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string>
<string name="screen_room_details_invite_people_title">"Personen einladen"</string>
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string> <string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string> <string name="screen_room_details_security_title">"Sicherheit"</string>
<string name="screen_room_details_topic_title">"Thema"</string> <string name="screen_room_details_topic_title">"Thema"</string>

View file

@ -20,8 +20,7 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.LeaveRoomWarning import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
@ -34,7 +33,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -44,9 +42,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -62,7 +57,7 @@ class RoomDetailsPresenterTests {
return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId) return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
} }
} }
return RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers, roomMemberDetailsPresenterFactory) return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake())
} }
@Test @Test
@ -155,103 +150,6 @@ class RoomDetailsPresenterTests {
} }
} }
@Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
}
}
@Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
}
}
@Test
fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
}
}
@Test
fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents()
}
// Membership observer should receive a 'left room' change
roomMembershipObserver.updates.take(1)
.onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) }
.collect()
}
@Test
fun `present - ClearError removes any error present`() = runTest {
val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable())
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull()
errorState.eventSink(RoomDetailsEvent.ClearError)
Truth.assertThat(awaitItem().error).isNull()
}
}
@Test @Test
fun `present - initial state when user can invite others to room`() = runTest { fun `present - initial state when user can invite others to room`() = runTest {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {

View file

@ -36,6 +36,7 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onSettingsClicked() fun onSettingsClicked()
fun onSessionVerificationClicked() fun onSessionVerificationClicked()
fun onInvitesClicked() fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId)
} }
} }

View file

@ -48,8 +48,10 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.features.invitelist.api) implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
implementation(libs.accompanist.placeholder) implementation(libs.accompanist.placeholder)
api(projects.features.roomlist.api) api(projects.features.roomlist.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
@ -62,10 +64,12 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
} }

View file

@ -1,330 +0,0 @@
/*
* 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.roomlist.impl
import android.content.Context
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
@ContributesBinding(SessionScope::class)
class DefaultRoomLastMessageFormatter @Inject constructor(
// TODO replace with StringProvider
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
) : RoomLastMessageFormatter {
override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.sender == matrixClient.sessionId
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
RedactedContent -> {
val message = context.getString(StringR.string.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is StickerContent -> {
content.body
}
is UnableToDecryptContent -> {
val message = context.getString(StringR.string.common_decryption_error)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is RoomMembershipContent -> {
processRoomMembershipChange(content, senderDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
processProfileChangeContent(content, senderDisplayName, isOutgoing)
}
is StateContent -> {
processRoomStateChange(content, senderDisplayName, isOutgoing)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(context.getString(StringR.string.common_unsupported_event), senderDisplayName, isDmRoom)
}
}
}
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
val messageType: MessageType = messageContent.type ?: return null
val internalMessage = when (messageType) {
// Doesn't need a prefix
is EmoteMessageType -> {
return "- $senderDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.body
}
is VideoMessageType -> {
context.getString(StringR.string.common_video)
}
is ImageMessageType -> {
context.getString(StringR.string.common_image)
}
is FileMessageType -> {
context.getString(StringR.string.common_file)
}
is AudioMessageType -> {
context.getString(StringR.string.common_audio)
}
UnknownMessageType -> {
context.getString(StringR.string.common_unsupported_event)
}
is NoticeMessageType -> {
messageType.body
}
}
return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom)
}
private fun processRoomMembershipChange(membershipContent: RoomMembershipContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? {
val userId = membershipContent.userId
val memberIsYou = userId == matrixClient.sessionId
return when (val change = membershipContent.change) {
MembershipChange.JOINED -> if (memberIsYou) {
context.getString(R.string.state_event_room_join_by_you)
} else {
context.getString(R.string.state_event_room_join, userId.value)
}
MembershipChange.LEFT -> if (memberIsYou) {
context.getString(R.string.state_event_room_leave_by_you)
} else {
context.getString(R.string.state_event_room_leave, userId.value)
}
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
context.getString(R.string.state_event_room_ban_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_ban, senderDisplayName, userId.value)
}
MembershipChange.UNBANNED -> if (senderIsYou) {
context.getString(R.string.state_event_room_unban_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_unban, senderDisplayName, userId.value)
}
MembershipChange.KICKED -> if (senderIsYou) {
context.getString(R.string.state_event_room_remove_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_remove, senderDisplayName, userId.value)
}
MembershipChange.INVITED -> if (senderIsYou) {
context.getString(R.string.state_event_room_invite_by_you, userId.value)
} else if (memberIsYou) {
context.getString(R.string.state_event_room_invite_you, senderDisplayName)
} else {
context.getString(R.string.state_event_room_invite, senderDisplayName, userId.value)
}
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_invite_accepted_by_you)
} else {
context.getString(R.string.state_event_room_invite_accepted, userId.value)
}
MembershipChange.INVITATION_REJECTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_reject_by_you)
} else {
context.getString(R.string.state_event_room_reject, userId.value)
}
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
context.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value)
}
MembershipChange.KNOCKED -> if (memberIsYou) {
context.getString(R.string.state_event_room_knock_by_you)
} else {
context.getString(R.string.state_event_room_knock, userId.value)
}
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
context.getString(R.string.state_event_room_knock_accepted_by_you, userId.value)
} else {
context.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value)
}
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
context.getString(R.string.state_event_room_knock_retracted_by_you)
} else {
context.getString(R.string.state_event_room_knock_retracted, userId.value)
}
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
context.getString(R.string.state_event_room_knock_denied_by_you, userId.value)
} else if (memberIsYou) {
context.getString(R.string.state_event_room_knock_denied_you, senderDisplayName)
} else {
context.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value)
}
else -> {
Timber.v("Filtering timeline item for room membership: $membershipContent")
null
}
}
}
private fun processRoomStateChange(stateContent: StateContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? {
return when (val content = stateContent.content) {
is OtherState.RoomAvatar -> {
val hasAvatarUrl = content.url != null
when {
senderIsYou && hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_changed_by_you)
senderIsYou && !hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_removed_by_you)
!senderIsYou && hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_changed, senderDisplayName)
else -> context.getString(R.string.state_event_room_avatar_removed, senderDisplayName)
}
}
is OtherState.RoomCreate -> {
if (senderIsYou) {
context.getString(R.string.state_event_room_created_by_you)
} else {
context.getString(R.string.state_event_room_created, senderDisplayName)
}
}
is OtherState.RoomEncryption -> context.getString(StringR.string.common_encryption_enabled)
is OtherState.RoomName -> {
val hasRoomName = content.name != null
when {
senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed_by_you, content.name)
senderIsYou && !hasRoomName -> context.getString(R.string.state_event_room_name_removed_by_you)
!senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name)
else -> context.getString(R.string.state_event_room_name_removed, senderDisplayName)
}
}
is OtherState.RoomThirdPartyInvite -> {
if (content.displayName == null) {
Timber.e("RoomThirdPartyInvite undisplayable due to missing name")
return null
}
if (senderIsYou) {
context.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName)
} else {
context.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName)
}
}
is OtherState.RoomTopic -> {
val hasRoomTopic = content.topic != null
when {
senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed_by_you, content.topic)
senderIsYou && !hasRoomTopic -> context.getString(R.string.state_event_room_topic_removed_by_you)
!senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic)
else -> context.getString(R.string.state_event_room_topic_removed, senderDisplayName)
}
}
else -> {
Timber.v("Filtering timeline item for room state change: $content")
null
}
}
}
private fun processProfileChangeContent(
profileChangeContent: ProfileChangeContent,
senderDisplayName: String,
senderIsYou: Boolean
): String? = profileChangeContent.run {
val displayNameChanged = displayName != prevDisplayName
val avatarChanged = avatarUrl != prevAvatarUrl
return when {
avatarChanged && displayNameChanged -> {
val message = processProfileChangeContent(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou)
val avatarChangedToo = context.getString(R.string.state_event_avatar_changed_too)
"$message\n$avatarChangedToo"
}
displayNameChanged -> {
if (displayName != null && prevDisplayName != null) {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName)
} else {
context.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName)
}
} else if (displayName != null) {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_set_by_you, displayName)
} else {
context.getString(R.string.state_event_display_name_set, senderDisplayName, displayName)
}
} else {
if (senderIsYou) {
context.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName)
} else {
context.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName)
}
}
}
avatarChanged -> {
if (senderIsYou) {
context.getString(R.string.state_event_avatar_url_changed_by_you)
} else {
context.getString(R.string.state_event_avatar_url_changed, senderDisplayName)
}
}
else -> null
}
}
private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) {
message
} else {
prefix(message, senderDisplayName)
}
private fun prefix(message: String, senderDisplayName: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(senderDisplayName)
}
append(": ")
append(message)
}
}
}

View file

@ -0,0 +1,148 @@
/*
* 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.roomlist.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
eventSink: (RoomListEvents) -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
) {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
onRoomSettingsClicked = {
eventSink(RoomListEvents.HideContextMenu)
onRoomSettingsClicked(it)
},
onLeaveRoomClicked = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
}
)
}
}
@Composable
private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onLeaveRoomClicked: (roomId: RoomId) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
ListItem(
headlineContent = {
Text(
text = contextMenu.roomName,
fontWeight = FontWeight.Bold,
)
}
)
ListItem(
headlineContent = {
Text(text = stringResource(id = StringR.string.common_settings))
},
modifier = Modifier.clickable { onRoomSettingsClicked(contextMenu.roomId) },
leadingContent = {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(id = StringR.string.common_settings),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
)
ListItem(
headlineContent = {
Text(
text = stringResource(id = StringR.string.action_leave_room),
color = ElementTheme.colors.textActionCritical,
)
},
modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) },
leadingContent = {
Icon(
resourceId = VectorIcons.DoorOpen,
contentDescription = stringResource(id = StringR.string.action_leave_room),
modifier = Modifier.size(20.dp),
tint = ElementTheme.colors.textActionCritical,
)
}
)
Spacer(modifier = Modifier.height(32.dp))
}
}
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
// see: https://issuetracker.google.com/issues/283843380
// Remove this preview when the issue is fixed.
@Preview
@Composable
internal fun RoomListModalBottomSheetContentLightPreview() =
ElementPreviewLight { ContentToPreview() }
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
// see: https://issuetracker.google.com/issues/283843380
// Remove this preview when the issue is fixed.
@Preview
@Composable
internal fun RoomListModalBottomSheetContentDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RoomListModalBottomSheetContent(
contextMenu = RoomListState.ContextMenu.Shown(
roomId = RoomId(value = "!aRoom:aDomain"),
roomName = "aRoom"
),
onRoomSettingsClicked = {},
onLeaveRoomClicked = {}
)
}

View file

@ -16,9 +16,15 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface RoomListEvents { sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
object DismissRequestVerificationPrompt : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents
object ToggleSearchResults : RoomListEvents object ToggleSearchResults : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
object HideContextMenu : RoomListEvents
data class LeaveRoom(val roomId: RoomId) : RoomListEvents
} }

View file

@ -56,17 +56,22 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() } plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
} }
private fun onRoomSettingsClicked(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
RoomListView( RoomListView(
state = state, state = state,
modifier = modifier,
onRoomClicked = this::onRoomClicked, onRoomClicked = this::onRoomClicked,
onOpenSettings = this::onOpenSettings, onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked, onCreateRoomClicked = this::onCreateRoomClicked,
onVerifyClicked = this::onSessionVerificationClicked, onVerifyClicked = this::onSessionVerificationClicked,
onInvitesClicked = this::onInvitesClicked, onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked,
modifier = modifier,
) )
} }
} }

View file

@ -28,6 +28,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -37,6 +39,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -62,10 +65,12 @@ class RoomListPresenter @Inject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val inviteStateDataSource: InviteStateDataSource, private val inviteStateDataSource: InviteStateDataSource,
private val leaveRoomPresenter: LeaveRoomPresenter,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
@Composable @Composable
override fun present(): RoomListState { override fun present(): RoomListState {
val leaveRoomState = leaveRoomPresenter.present()
val matrixUser: MutableState<MatrixUser?> = rememberSaveable { val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
@ -97,6 +102,8 @@ class RoomListPresenter @Inject constructor(
var displaySearchResults by rememberSaveable { mutableStateOf(false) } var displaySearchResults by rememberSaveable { mutableStateOf(false) }
var contextMenu by remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
fun handleEvents(event: RoomListEvents) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateFilter -> filter = event.newFilter
@ -108,6 +115,14 @@ class RoomListPresenter @Inject constructor(
} }
displaySearchResults = !displaySearchResults displaySearchResults = !displaySearchResults
} }
is RoomListEvents.ShowContextMenu -> {
contextMenu = RoomListState.ContextMenu.Shown(
roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name
)
}
is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
} }
} }
@ -132,6 +147,8 @@ class RoomListPresenter @Inject constructor(
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(), invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults, displaySearchResults = displaySearchResults,
contextMenu = contextMenu,
leaveRoomState = leaveRoomState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -183,7 +200,7 @@ class RoomListPresenter @Inject constructor(
hasUnread = roomSummary.details.unreadNotificationCount > 0, hasUnread = roomSummary.details.unreadNotificationCount > 0,
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
lastMessage = roomSummary.details.lastMessage?.let { message -> lastMessage = roomSummary.details.lastMessage?.let { message ->
roomLastMessageFormatter.processMessageItem(message.event, roomSummary.details.isDirect) roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(), }.orEmpty(),
avatarData = avatarData, avatarData = avatarData,
) )

View file

@ -17,8 +17,10 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -33,8 +35,18 @@ data class RoomListState(
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState, val invitesState: InvitesState,
val displaySearchResults: Boolean, val displaySearchResults: Boolean,
val eventSink: (RoomListEvents) -> Unit val contextMenu: ContextMenu,
) val leaveRoomState: LeaveRoomState,
val eventSink: (RoomListEvents) -> Unit,
) {
sealed interface ContextMenu {
object Hidden : ContextMenu
data class Shown(
val roomId: RoomId,
val roomName: String,
) : ContextMenu
}
}
enum class InvitesState { enum class InvitesState {
NoInvites, NoInvites,

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -39,6 +40,9 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(invitesState = InvitesState.NewInvites), aRoomListState().copy(invitesState = InvitesState.NewInvites),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true), aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name"
))
) )
} }
@ -52,6 +56,8 @@ internal fun aRoomListState() = RoomListState(
displayVerificationPrompt = false, displayVerificationPrompt = false,
invitesState = InvitesState.NoInvites, invitesState = InvitesState.NoInvites,
displaySearchResults = false, displaySearchResults = false,
contextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState = LeaveRoomState(),
eventSink = {} eventSink = {}
) )

View file

@ -16,7 +16,6 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -62,6 +61,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
@ -88,20 +88,38 @@ import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun RoomListView( fun RoomListView(
state: RoomListState, state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
onSettingsClicked: () -> Unit,
onVerifyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {},
onOpenSettings: () -> Unit = {},
onVerifyClicked: () -> Unit = {},
onCreateRoomClicked: () -> Unit = {},
onInvitesClicked: () -> Unit = {},
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
Box { Box {
fun onRoomLongClicked(
roomListRoomSummary: RoomListRoomSummary
) {
state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary))
}
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
RoomListContextMenu(
contextMenu = state.contextMenu,
eventSink = state.eventSink,
onRoomSettingsClicked = onRoomSettingsClicked,
)
}
LeaveRoomView(state = state.leaveRoomState)
RoomListContent( RoomListContent(
state = state, state = state,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onOpenSettings = onOpenSettings, onRoomLongClicked = { onRoomLongClicked(it) },
onOpenSettings = onSettingsClicked,
onVerifyClicked = onVerifyClicked, onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked, onInvitesClicked = onInvitesClicked,
@ -110,6 +128,7 @@ fun RoomListView(
RoomListSearchResultView( RoomListSearchResultView(
state = state, state = state,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
@ -125,12 +144,12 @@ fun RoomListContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onVerifyClicked: () -> Unit = {}, onVerifyClicked: () -> Unit = {},
onRoomClicked: (RoomId) -> Unit = {}, onRoomClicked: (RoomId) -> Unit = {},
onRoomLongClicked: (RoomListRoomSummary) -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onCreateRoomClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {},
onInvitesClicked: () -> Unit = {}, onInvitesClicked: () -> Unit = {},
) { ) {
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
if (room.roomId == null) return
onRoomClicked(room.roomId) onRoomClicked(room.roomId)
} }
@ -237,7 +256,11 @@ fun RoomListContent(
items = state.roomList, items = state.roomList,
contentType = { room -> room.contentType() }, contentType = { room -> room.contentType() },
) { room -> ) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked) RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
} }
} }
} }
@ -339,13 +362,25 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl
@Composable @Composable
private fun ContentToPreview(state: RoomListState) { private fun ContentToPreview(state: RoomListState) {
RoomListView(state) RoomListView(
state = state,
onRoomClicked = {},
onSettingsClicked = {},
onVerifyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {}
)
} }
@Preview @Preview
@Composable @Composable
internal fun RoomListSearchResultContentPreview() { internal fun RoomListSearchResultContentPreview() {
ElementPreviewLight { ElementPreviewLight {
RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {}) RoomListSearchResultContent(
state = aRoomListState(),
onRoomClicked = {},
onRoomLongClicked = {}
)
} }
} }

View file

@ -16,8 +16,9 @@
package io.element.android.features.roomlist.impl.components package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -70,17 +71,20 @@ import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
private val minHeight = 72.dp private val minHeight = 72.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
internal fun RoomSummaryRow( internal fun RoomSummaryRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (RoomListRoomSummary) -> Unit = {},
) { ) {
val clickModifier = if (room.isPlaceholder) { val clickModifier = if (room.isPlaceholder) {
modifier modifier
} else { } else {
modifier.clickable( modifier.combinedClickable(
onClick = { onClick(room) }, onClick = { onClick(room) },
onLongClick = { onLongClick(room) },
indication = rememberRipple(), indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
@ -214,5 +218,9 @@ internal fun RoomSummaryRowDarkPreview(@PreviewParameter(RoomListRoomSummaryProv
@Composable @Composable
private fun ContentToPreview(data: RoomListRoomSummary) { private fun ContentToPreview(data: RoomListRoomSummary) {
RoomSummaryRow(data) RoomSummaryRow(
room = data,
onClick = {},
onLongClick = {}
)
} }

View file

@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
@Immutable @Immutable
data class RoomListRoomSummary( data class RoomListRoomSummary(
val id: String, val id: String,
val roomId: RoomId?, val roomId: RoomId,
val name: String = "", val name: String = "",
val hasUnread: Boolean = false, val hasUnread: Boolean = false,
val timestamp: String? = null, val timestamp: String? = null,

View file

@ -17,13 +17,14 @@
package io.element.android.features.roomlist.impl.model package io.element.android.features.roomlist.impl.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
object RoomListRoomSummaryPlaceholders { object RoomListRoomSummaryPlaceholders {
fun create(id: String): RoomListRoomSummary { fun create(id: String): RoomListRoomSummary {
return RoomListRoomSummary( return RoomListRoomSummary(
id = id, id = id,
roomId = null, roomId = RoomId("!aRoom:domain"),
isPlaceholder = true, isPlaceholder = true,
name = "Short name", name = "Short name",
timestamp = "hh:mm", timestamp = "hh:mm",

View file

@ -70,6 +70,7 @@ import io.element.android.libraries.ui.strings.R
internal fun RoomListSearchResultView( internal fun RoomListSearchResultView(
state: RoomListState, state: RoomListState,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@ -85,7 +86,11 @@ internal fun RoomListSearchResultView(
}) })
) { ) {
if (state.displaySearchResults) { if (state.displaySearchResults) {
RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked) RoomListSearchResultContent(
state = state,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
)
} }
} }
} }
@ -96,6 +101,7 @@ internal fun RoomListSearchResultView(
internal fun RoomListSearchResultContent( internal fun RoomListSearchResultContent(
state: RoomListState, state: RoomListState,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val borderColor = MaterialTheme.colorScheme.tertiary val borderColor = MaterialTheme.colorScheme.tertiary
@ -104,7 +110,6 @@ internal fun RoomListSearchResultContent(
state.eventSink(RoomListEvents.ToggleSearchResults) state.eventSink(RoomListEvents.ToggleSearchResults)
} }
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
if (room.roomId == null) return
onRoomClicked(room.roomId) onRoomClicked(room.roomId)
} }
Scaffold( Scaffold(
@ -197,7 +202,11 @@ internal fun RoomListSearchResultContent(
items = state.filteredRoomList, items = state.filteredRoomList,
contentType = { room -> room.contentType() }, contentType = { room -> room.contentType() },
) { room -> ) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked) RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
} }
} }
} }

View file

@ -1,40 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_main_space_title">"Alle Chats"</string> <string name="screen_roomlist_main_space_title">"Alle Chats"</string>
<string name="state_event_avatar_changed_too">"(Avatar wurde ebenfalls geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat seinen Avatar geändert"</string>
<string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string>
<string name="state_event_display_name_changed_from">"%1$s hat den Anzeigenamen von %2$s in %3$s geändert"</string>
<string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s in %2$s geändert"</string>
<string name="state_event_display_name_removed">"%1$s hat den Anzeigenamen entfernt (war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat den Anzeigenamen auf %2$s gesetzt"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s gesetzt"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Du hast den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Raum-Avatar entfernt"</string>
<string name="state_event_room_created">"%1$s hat den Raum erstellt"</string>
<string name="state_event_room_created_by_you">"Du hast den Raum erstellt"</string>
<string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string>
<string name="state_event_room_invite_accepted">"%1$s hat die Einladung angenommen"</string>
<string name="state_event_room_invite_accepted_by_you">"Du hast die Einladung angenommen"</string>
<string name="state_event_room_invite_by_you">"Du hast %1$s eingeladen"</string>
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s ist dem Raum beigetreten"</string>
<string name="state_event_room_join_by_you">"Du bist dem Raum beigetreten"</string>
<string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string>
<string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string>
<string name="state_event_room_leave_by_you">"Du hast den Raum verlassen"</string>
<string name="state_event_room_name_changed">"%1$s hat den Raumnamen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Sie haben den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Raumnamen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Du hast den Raumnamen entfernt"</string>
<string name="state_event_room_reject">"%1$s hat die Einladung abgelehnt"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
<string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string>
<string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string>
<string name="state_event_room_topic_changed">"%1$s hat das Thema geändert zu: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sie haben das Thema geändert zu: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Raumthema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Du hast das Raumthema entfernt"</string>
</resources> </resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Todos los chats"</string> <string name="screen_roomlist_main_space_title">"Todos los chats"</string>
<string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string> <string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string>
<string name="session_verification_banner_title">"Accede a tu historial de mensajes"</string> <string name="session_verification_banner_title">"Accede a tu historial de mensajes"</string>
<string name="state_event_avatar_changed_too">"(el avatar también cambió)"</string>
<string name="state_event_avatar_url_changed">"%1$s cambió su avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Cambiaste tu avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s cambió su nombre de %2$s a %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Cambiaste tu nombre de %1$s a %2$s"</string>
<string name="state_event_display_name_removed">"%1$s eliminó su nombre (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Eliminaste tu nombre (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s cambió su nombre a %2$s"</string>
<string name="state_event_display_name_set_by_you">"Cambiaste tu nombre a %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s cambió el avatar de la sala"</string>
<string name="state_event_room_avatar_changed_by_you">"Cambiaste el avatar de la sala"</string>
<string name="state_event_room_avatar_removed">"%1$s eliminó el avatar de la sala"</string>
<string name="state_event_room_avatar_removed_by_you">"Eliminaste el avatar de la sala"</string>
<string name="state_event_room_ban">"%1$s expulsó permanentemente a %2$s"</string>
<string name="state_event_room_ban_by_you">"Expulsaste permanentemente a %1$s"</string>
<string name="state_event_room_created">"%1$s creó la sala"</string>
<string name="state_event_room_created_by_you">"Tú creaste la sala"</string>
<string name="state_event_room_invite">"%1$s invitó a %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s aceptó la invitación"</string>
<string name="state_event_room_invite_accepted_by_you">"Aceptaste la invitación"</string>
<string name="state_event_room_invite_by_you">"Invitaste a %1$s"</string>
<string name="state_event_room_invite_you">"%1$s te invitó."</string>
<string name="state_event_room_join">"%1$s se unió a la sala"</string>
<string name="state_event_room_join_by_you">"Te uniste a la sala"</string>
<string name="state_event_room_knock">"%1$s solicitó unirse"</string>
<string name="state_event_room_knock_accepted">"%1$s permitió que %2$s se uniera"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s te permitió unirte"</string>
<string name="state_event_room_knock_by_you">"Solicitaste unirte"</string>
<string name="state_event_room_knock_denied">"%1$s rechazó la solicitud de %2$s para unirse"</string>
<string name="state_event_room_knock_denied_by_you">"Rechazaste la solicitud de %1$s para unirte"</string>
<string name="state_event_room_knock_denied_you">"%1$s rechazó su solicitud para unirte"</string>
<string name="state_event_room_knock_retracted">"%1$s ya no está interesado en unirse"</string>
<string name="state_event_room_knock_retracted_by_you">"Cancelaste tu solicitud de unirte"</string>
<string name="state_event_room_leave">"%1$s salió de la sala"</string>
<string name="state_event_room_leave_by_you">"Saliste de la sala"</string>
<string name="state_event_room_name_changed">"%1$s cambió el nombre de la sala a: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Cambiaste el nombre de la sala a: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s eliminó el nombre de la sala"</string>
<string name="state_event_room_name_removed_by_you">"Eliminaste el nombre de la sala"</string>
<string name="state_event_room_reject">"%1$s rechazó la invitación"</string>
<string name="state_event_room_reject_by_you">"Rechazaste la invitación"</string>
<string name="state_event_room_remove">"%1$s echó a %2$s"</string>
<string name="state_event_room_remove_by_you">"Echaste a %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s envió una invitación a %2$s para unirse a la sala"</string>
<string name="state_event_room_third_party_invite_by_you">"Enviaste una invitación a %1$s para unirse a la sala"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s revocó la invitación a %2$s para unirse a la sala"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Revocaste la invitación de %1$s para unirse a la sala"</string>
<string name="state_event_room_topic_changed">"%1$s cambió el tema a: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Cambiaste el tema a: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s eliminó el tema de la sala"</string>
<string name="state_event_room_topic_removed_by_you">"Eliminaste el tema de la sala"</string>
<string name="state_event_room_unban">"%1$s readmitió a %2$s"</string>
<string name="state_event_room_unban_by_you">"Readmitiste a %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s realizó un cambio desconocido en su membresía"</string>
</resources> </resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Tutte le conversazioni"</string> <string name="screen_roomlist_main_space_title">"Tutte le conversazioni"</string>
<string name="session_verification_banner_message">"Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati."</string> <string name="session_verification_banner_message">"Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati."</string>
<string name="session_verification_banner_title">"Accedi alla cronologia dei messaggi"</string> <string name="session_verification_banner_title">"Accedi alla cronologia dei messaggi"</string>
<string name="state_event_avatar_changed_too">"(anche l\'avatar è stato cambiato)"</string>
<string name="state_event_avatar_url_changed">"%1$s ha cambiato il proprio avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Hai cambiato il tuo avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Hai cambiato il tuo nome visualizzato da %1$s a %2$s"</string>
<string name="state_event_display_name_removed">"%1$s ha rimosso il proprio nome visualizzato (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Hai rimosso il tuo nome visualizzato (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s ha impostato il proprio nome visualizzato su %2$s"</string>
<string name="state_event_display_name_set_by_you">"Hai impostato il tuo nome visualizzato su %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s ha cambiato l\'avatar della stanza"</string>
<string name="state_event_room_avatar_changed_by_you">"Hai cambiato l\'avatar della stanza"</string>
<string name="state_event_room_avatar_removed">"%1$s ha rimosso l\'avatar della stanza"</string>
<string name="state_event_room_avatar_removed_by_you">"Hai rimosso l\'avatar della stanza"</string>
<string name="state_event_room_ban">"%1$s ha rimosso %2$s"</string>
<string name="state_event_room_ban_by_you">"Hai rimosso %1$s"</string>
<string name="state_event_room_created">"%1$s ha creato la stanza"</string>
<string name="state_event_room_created_by_you">"Hai creato la stanza"</string>
<string name="state_event_room_invite">"%1$s ha invitato %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s ha accettato l\'invito"</string>
<string name="state_event_room_invite_accepted_by_you">"Hai accettato l\'invito"</string>
<string name="state_event_room_invite_by_you">"Hai invitato %1$s"</string>
<string name="state_event_room_invite_you">"%1$s ti ha invitato"</string>
<string name="state_event_room_join">"%1$s si è unito alla stanza"</string>
<string name="state_event_room_join_by_you">"Ti sei unito alla stanza"</string>
<string name="state_event_room_knock">"%1$s ha chiesto di unirsi"</string>
<string name="state_event_room_knock_accepted">"%1$s ha permesso a %2$s di unirsi"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s ti ha permesso di unirti"</string>
<string name="state_event_room_knock_by_you">"Hai richiesto di unirti"</string>
<string name="state_event_room_knock_denied">"%1$s ha rifiutato la richiesta di unirsi di %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Hai rifiutato la richiesta di unirsi di %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s ha rifiutato la tua richiesta di unirti"</string>
<string name="state_event_room_knock_retracted">"%1$s non è più interessato a partecipare"</string>
<string name="state_event_room_knock_retracted_by_you">"Hai annullato la tua richiesta di unirti"</string>
<string name="state_event_room_leave">"%1$s ha lasciato la stanza"</string>
<string name="state_event_room_leave_by_you">"Hai lasciato la stanza"</string>
<string name="state_event_room_name_changed">"%1$s ha cambiato il nome della stanza in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Hai cambiato il nome della stanza in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s ha rimosso il nome della stanza"</string>
<string name="state_event_room_name_removed_by_you">"Hai rimosso il nome della stanza"</string>
<string name="state_event_room_reject">"%1$s ha rifiutato l\'invito"</string>
<string name="state_event_room_reject_by_you">"Hai rifiutato l\'invito"</string>
<string name="state_event_room_remove">"%1$s ha rimosso %2$s"</string>
<string name="state_event_room_remove_by_you">"Hai rimosso %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s ha inviato un invito a %2$s per unirsi alla stanza"</string>
<string name="state_event_room_third_party_invite_by_you">"Hai inviato un invito a %1$s per unirsi alla stanza"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza."</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Hai revocato l\'invito a %1$s a universi alla stanza"</string>
<string name="state_event_room_topic_changed">"%1$s ha cambiato l\'oggetto in: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Hai cambiato l\'oggetto in: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s ha rimosso l\'oggetto della stanza"</string>
<string name="state_event_room_topic_removed_by_you">"Hai rimosso l\'oggetto della stanza"</string>
<string name="state_event_room_unban">"%1$s ha sbloccato %2$s"</string>
<string name="state_event_room_unban_by_you">"Hai sbloccato %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s ha apportato una modifica sconosciuta alla propria iscrizione"</string>
</resources> </resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"Toate conversatiile"</string> <string name="screen_roomlist_main_space_title">"Toate conversatiile"</string>
<string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."</string> <string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."</string>
<string name="session_verification_banner_title">"Accesați istoricul mesajelor"</string> <string name="session_verification_banner_title">"Accesați istoricul mesajelor"</string>
<string name="state_event_avatar_changed_too">"(s-a schimbat si avatarul)"</string>
<string name="state_event_avatar_url_changed">"%1$s și-a schimbat avatarul"</string>
<string name="state_event_avatar_url_changed_by_you">"V-ați schimbat avatarul"</string>
<string name="state_event_display_name_changed_from">"%1$s și-a schimbat numele din %2$s în %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"V-ați schimbat numele din %1$s în %2$s"</string>
<string name="state_event_display_name_removed">"%1$s și-a sters numele (era %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"V-ați sters numele (era %1$s)"</string>
<string name="state_event_display_name_set">"%1$s și-a schimbat numele %2$s"</string>
<string name="state_event_display_name_set_by_you">"V-ați schimbat numele în %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s a schimbat avatarul camerei"</string>
<string name="state_event_room_avatar_changed_by_you">"Ați schimbat avatarul camerei"</string>
<string name="state_event_room_avatar_removed">"%1$s a șters avatarul camerei"</string>
<string name="state_event_room_avatar_removed_by_you">"Ați șters avatarul camerei"</string>
<string name="state_event_room_ban">"%1$s a adăugat o interdicție pentru %2$s"</string>
<string name="state_event_room_ban_by_you">"Ați adăugat o interdicție pentru %1$s"</string>
<string name="state_event_room_created">"%1$s a creat camera"</string>
<string name="state_event_room_created_by_you">"Ați creat camera"</string>
<string name="state_event_room_invite">"%1$s l-a invitat pe %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s a acceptat invitația"</string>
<string name="state_event_room_invite_accepted_by_you">"Ați acceptat invitația"</string>
<string name="state_event_room_invite_by_you">"L-ați invitat pe %1$s"</string>
<string name="state_event_room_invite_you">"%1$s v-a invitat"</string>
<string name="state_event_room_join">"%1$s a intrat în cameră"</string>
<string name="state_event_room_join_by_you">"Ați intrat în cameră"</string>
<string name="state_event_room_knock">"%1$s a solicitat să se alăture camerei"</string>
<string name="state_event_room_knock_accepted">"%1$s i-a permis lui %2$s să se alăture camerei"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s v-a permis să vă alăturați camerei"</string>
<string name="state_event_room_knock_by_you">"Ați solicitat să vă alăturați camerei"</string>
<string name="state_event_room_knock_denied">"%1$s a respins solicitarea de alăturare a lui %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Ați respins solicitarea de alăturare a lui %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s a respins cererea dumneavoastră de alăturare"</string>
<string name="state_event_room_knock_retracted">"%1$s nu mai este interesat să se alăture camerei"</string>
<string name="state_event_room_knock_retracted_by_you">"Ați anulat cererea de alăturare"</string>
<string name="state_event_room_leave">"%1$s a părăsit camera"</string>
<string name="state_event_room_leave_by_you">"Ați părăsit camera"</string>
<string name="state_event_room_name_changed">"%1$s a schimbat numele camerei în: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Ați schimbat numele camerei în: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s a sters numele camerei"</string>
<string name="state_event_room_name_removed_by_you">"Ați șters numele camerei"</string>
<string name="state_event_room_reject">"%1$s a respins invitația"</string>
<string name="state_event_room_reject_by_you">"Ați respins invitația"</string>
<string name="state_event_room_remove">"%1$s l-a îndepărtat pe %2$s"</string>
<string name="state_event_room_remove_by_you">"L-ați îndepărtat pe %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s a trimis o invitație către %2$s pentru a se alătura camerei"</string>
<string name="state_event_room_third_party_invite_by_you">"Ați trimis o invitație către %1$s pentru a se alătura camerei"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s a revocat invitația pentru %2$s de a se alătura camerei"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Ați revocat invitația pentru %1$s de a se alătura camerei"</string>
<string name="state_event_room_topic_changed">"%1$s a schimbat subiectul în: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Ați schimbat subiectul în: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s a șters subiectul camerei"</string>
<string name="state_event_room_topic_removed_by_you">"Ați șters subiectul camerei"</string>
<string name="state_event_room_unban">"%1$s a anulat interdicția pentru %2$s"</string>
<string name="state_event_room_unban_by_you">"Ați anulat interdicția pentru %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s a făcut o modificare necunoscută asupra calității sale de membru"</string>
</resources> </resources>

View file

@ -4,58 +4,4 @@
<string name="screen_roomlist_main_space_title">"All Chats"</string> <string name="screen_roomlist_main_space_title">"All Chats"</string>
<string name="session_verification_banner_message">"Looks like youre using a new device. Verify its you to access your encrypted messages."</string> <string name="session_verification_banner_message">"Looks like youre using a new device. Verify its you to access your encrypted messages."</string>
<string name="session_verification_banner_title">"Access your message history"</string> <string name="session_verification_banner_title">"Access your message history"</string>
<string name="state_event_avatar_changed_too">"(avatar was changed too)"</string>
<string name="state_event_avatar_url_changed">"%1$s changed their avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"You changed your avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s changed their display name from %2$s to %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"You changed your display name from %1$s to %2$s"</string>
<string name="state_event_display_name_removed">"%1$s removed their display name (it was %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"You removed your display name (it was %1$s)"</string>
<string name="state_event_display_name_set">"%1$s set their display name to %2$s"</string>
<string name="state_event_display_name_set_by_you">"You set your display name to %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s changed the room avatar"</string>
<string name="state_event_room_avatar_changed_by_you">"You changed the room avatar"</string>
<string name="state_event_room_avatar_removed">"%1$s removed the room avatar"</string>
<string name="state_event_room_avatar_removed_by_you">"You removed the room avatar"</string>
<string name="state_event_room_ban">"%1$s banned %2$s"</string>
<string name="state_event_room_ban_by_you">"You banned %1$s"</string>
<string name="state_event_room_created">"%1$s created the room"</string>
<string name="state_event_room_created_by_you">"You created the room"</string>
<string name="state_event_room_invite">"%1$s invited %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s accepted the invite"</string>
<string name="state_event_room_invite_accepted_by_you">"You accepted the invite"</string>
<string name="state_event_room_invite_by_you">"You invited %1$s"</string>
<string name="state_event_room_invite_you">"%1$s invited you"</string>
<string name="state_event_room_join">"%1$s joined the room"</string>
<string name="state_event_room_join_by_you">"You joined the room"</string>
<string name="state_event_room_knock">"%1$s requested to join"</string>
<string name="state_event_room_knock_accepted">"%1$s allowed %2$s to join"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s allowed you to join"</string>
<string name="state_event_room_knock_by_you">"You requested to join"</string>
<string name="state_event_room_knock_denied">"%1$s rejected %2$s\'s request to join"</string>
<string name="state_event_room_knock_denied_by_you">"You rejected %1$s\'s request to join"</string>
<string name="state_event_room_knock_denied_you">"%1$s rejected your request to join"</string>
<string name="state_event_room_knock_retracted">"%1$s is no longer interested in joining"</string>
<string name="state_event_room_knock_retracted_by_you">"You cancelled your request to join"</string>
<string name="state_event_room_leave">"%1$s left the room"</string>
<string name="state_event_room_leave_by_you">"You left the room"</string>
<string name="state_event_room_name_changed">"%1$s changed the room name to: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"You changed the room name to: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s removed the room name"</string>
<string name="state_event_room_name_removed_by_you">"You removed the room name"</string>
<string name="state_event_room_reject">"%1$s rejected the invitation"</string>
<string name="state_event_room_reject_by_you">"You rejected the invitation"</string>
<string name="state_event_room_remove">"%1$s removed %2$s"</string>
<string name="state_event_room_remove_by_you">"You removed %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s sent an invitation to %2$s to join the room"</string>
<string name="state_event_room_third_party_invite_by_you">"You sent an invitation to %1$s to join the room"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s revoked the invitation for %2$s to join the room"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"You revoked the invitation for %1$s to join the room"</string>
<string name="state_event_room_topic_changed">"%1$s changed the topic to: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"You changed the topic to: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s removed the room topic"</string>
<string name="state_event_room_topic_removed_by_you">"You removed the room topic"</string>
<string name="state_event_room_unban">"%1$s unbanned %2$s"</string>
<string name="state_event_room_unban_by_you">"You unbanned %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s made an unknown change to their membership"</string>
</resources> </resources>

View file

@ -20,12 +20,16 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@ -54,6 +58,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -82,6 +87,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -103,6 +109,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -132,6 +139,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -164,6 +172,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -202,6 +211,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -253,6 +263,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(), FakeInviteDataSource(),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -278,6 +289,7 @@ class RoomListPresenterTests {
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(inviteStateFlow), FakeInviteDataSource(inviteStateFlow),
LeaveRoomPresenterFake(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -297,6 +309,89 @@ class RoomListPresenterTests {
} }
} }
@Test
fun `present - show context menu`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
LeaveRoomPresenterFake(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val summary = aRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem()
Truth.assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
}
}
@Test
fun `present - hide context menu`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
LeaveRoomPresenterFake(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val summary = aRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem()
Truth.assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
shownState.eventSink(RoomListEvents.HideContextMenu)
val hiddenState = awaitItem()
Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
}
}
@Test
fun `present - leave room calls into leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
leaveRoomPresenter,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
}
}
private fun createDateFormatter(): LastMessageTimestampFormatter { private fun createDateFormatter(): LastMessageTimestampFormatter {
return FakeLastMessageTimestampFormatter().apply { return FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE) givenFormat(A_FORMATTED_DATE)

View file

@ -15,7 +15,7 @@ datastore = "1.0.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
recyclerview = "1.3.0" recyclerview = "1.3.0"
lifecycle = "2.6.1" lifecycle = "2.6.1"
activity = "1.7.1" activity = "1.7.2"
startup = "1.1.1" startup = "1.1.1"
media3 = "1.0.2" media3 = "1.0.2"
@ -46,7 +46,7 @@ telephoto = "0.3.0"
# DI # DI
dagger = "2.46.1" dagger = "2.46.1"
anvil = "2.4.5" anvil = "2.4.6"
# quality # quality
detekt = "1.22.0" detekt = "1.22.0"
@ -55,6 +55,8 @@ dependencygraph = "0.10"
[libraries] [libraries]
# Project # Project
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
# https://developer.android.com/studio/write/java8-support#library-desugaring-versions
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:32.0.0" google_firebase_bom = "com.google.firebase:firebase-bom:32.0.0"
@ -153,7 +155,7 @@ anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref =
anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" } anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" }
# Composer # Composer
wysiwyg = "io.element.android:wysiwyg:2.1.0" wysiwyg = "io.element.android:wysiwyg:2.2.1"
# Miscellaneous # Miscellaneous
# Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the # Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the

View file

@ -22,4 +22,5 @@ object VectorIcons {
val Delete = R.drawable.ic_baseline_delete_outline_24 val Delete = R.drawable.ic_baseline_delete_outline_24
val Reply = R.drawable.ic_baseline_reply_24 val Reply = R.drawable.ic_baseline_reply_24
val Edit = R.drawable.ic_baseline_edit_24 val Edit = R.drawable.ic_baseline_edit_24
val DoorOpen = R.drawable.ic_door_open_24
} }

View file

@ -0,0 +1,103 @@
/*
* 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.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.preview.PreviewGroup
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
content: @Composable ColumnScope.() -> Unit,
) {
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
content = content,
)
}
// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
@Preview(group = PreviewGroup.BottomSheets)
@Composable
internal fun ModalBottomSheetLightPreview() =
ElementPreviewLight { ContentToPreview() }
// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
@Preview(group = PreviewGroup.BottomSheets)
@Composable
internal fun ModalBottomSheetDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview() {
Box(
modifier = Modifier.fillMaxSize(),
) {
ModalBottomSheet(
onDismissRequest = {},
sheetState = SheetState(
skipPartiallyExpanded = true,
initialValue = SheetValue.Expanded,
skipHiddenState = true,
),
) {
Text(
text = "Sheet Content",
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp)
.background(color = Color.Green)
)
}
}
}

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="#000000">
<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,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,786L280,840ZM120,840L120,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L840,760L840,840L120,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760Z"/>
</vector>

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.eventformatter.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}

View file

@ -14,10 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.roomlist.impl package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
interface RoomLastMessageFormatter { interface RoomLastMessageFormatter {
fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence?
} }

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
interface TimelineEventFormatter {
fun format(event: EventTimelineItem): CharSequence?
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.eventformatter.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
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.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.eventformatter.api)
testImplementation(projects.services.toolbox.impl)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,150 @@
/*
* 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.eventformatter.impl
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
@ContributesBinding(SessionScope::class)
class DefaultRoomLastMessageFormatter @Inject constructor(
private val sp: StringProvider,
private val matrixClient: MatrixClient,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter,
) : RoomLastMessageFormatter {
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.sender == matrixClient.sessionId
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
RedactedContent -> {
val message = sp.getString(StringR.string.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is StickerContent -> {
content.body
}
is UnableToDecryptContent -> {
val message = sp.getString(StringR.string.common_decryption_error)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(sp.getString(StringR.string.common_unsupported_event), senderDisplayName, isDmRoom)
}
}
}
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
val messageType: MessageType = messageContent.type ?: return null
val internalMessage = when (messageType) {
// Doesn't need a prefix
is EmoteMessageType -> {
return "- $senderDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.body
}
is VideoMessageType -> {
sp.getString(StringR.string.common_video)
}
is ImageMessageType -> {
sp.getString(StringR.string.common_image)
}
is FileMessageType -> {
sp.getString(StringR.string.common_file)
}
is AudioMessageType -> {
sp.getString(StringR.string.common_audio)
}
UnknownMessageType -> {
sp.getString(StringR.string.common_unsupported_event)
}
is NoticeMessageType -> {
messageType.body
}
}
return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom)
}
private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) {
message
} else {
prefix(message, senderDisplayName)
}
private fun prefix(message: String, senderDisplayName: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(senderDisplayName)
}
append(": ")
append(message)
}
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.eventformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.ui.strings.R
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultTimelineEventFormatter @Inject constructor(
private val sp: StringProvider,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter,
) : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.sender == matrixClient.sessionId
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline)
}
RedactedContent,
is StickerContent,
is UnableToDecryptContent,
is MessageContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event: $event")
}
sp.getString(R.string.common_unsupported_event)
}
}
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.eventformatter.impl
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class ProfileChangeContentFormatter @Inject constructor(
private val sp: StringProvider,
) {
fun format(
profileChangeContent: ProfileChangeContent,
senderDisplayName: String,
senderIsYou: Boolean,
): String? = profileChangeContent.run {
val displayNameChanged = displayName != prevDisplayName
val avatarChanged = avatarUrl != prevAvatarUrl
return when {
avatarChanged && displayNameChanged -> {
val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou)
val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too)
"$message\n$avatarChangedToo"
}
displayNameChanged -> {
if (displayName != null && prevDisplayName != null) {
if (senderIsYou) {
sp.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName)
} else {
sp.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName)
}
} else if (displayName != null) {
if (senderIsYou) {
sp.getString(R.string.state_event_display_name_set_by_you, displayName)
} else {
sp.getString(R.string.state_event_display_name_set, senderDisplayName, displayName)
}
} else {
if (senderIsYou) {
sp.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName)
} else {
sp.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName)
}
}
}
avatarChanged -> {
if (senderIsYou) {
sp.getString(R.string.state_event_avatar_url_changed_by_you)
} else {
sp.getString(R.string.state_event_avatar_url_changed, senderDisplayName)
}
}
else -> null
}
}
}

View file

@ -0,0 +1,113 @@
/*
* 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.eventformatter.impl
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
class RoomMembershipContentFormatter @Inject constructor(
private val matrixClient: MatrixClient,
private val sp: StringProvider,
) {
fun format(
membershipContent: RoomMembershipContent,
senderDisplayName: String,
senderIsYou: Boolean,
): CharSequence? {
val userId = membershipContent.userId
val memberIsYou = userId == matrixClient.sessionId
return when (val change = membershipContent.change) {
MembershipChange.JOINED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_join_by_you)
} else {
sp.getString(R.string.state_event_room_join, userId.value)
}
MembershipChange.LEFT -> if (memberIsYou) {
sp.getString(R.string.state_event_room_leave_by_you)
} else {
sp.getString(R.string.state_event_room_leave, userId.value)
}
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_ban_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_ban, senderDisplayName, userId.value)
}
MembershipChange.UNBANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_unban_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_unban, senderDisplayName, userId.value)
}
MembershipChange.KICKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_remove_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_remove, senderDisplayName, userId.value)
}
MembershipChange.INVITED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_invite_by_you, userId.value)
} else if (memberIsYou) {
sp.getString(R.string.state_event_room_invite_you, senderDisplayName)
} else {
sp.getString(R.string.state_event_room_invite, senderDisplayName, userId.value)
}
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_invite_accepted_by_you)
} else {
sp.getString(R.string.state_event_room_invite_accepted, userId.value)
}
MembershipChange.INVITATION_REJECTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_reject_by_you)
} else {
sp.getString(R.string.state_event_room_reject, userId.value)
}
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value)
}
MembershipChange.KNOCKED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_by_you)
} else {
sp.getString(R.string.state_event_room_knock, userId.value)
}
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value)
}
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_retracted_by_you)
} else {
sp.getString(R.string.state_event_room_knock_retracted, userId.value)
}
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value)
} else if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_denied_you, senderDisplayName)
} else {
sp.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value)
}
else -> {
Timber.v("Filtering timeline item for room membership: $membershipContent")
null
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more