Merge branch 'develop' into jonny/timeline-poll-edited

This commit is contained in:
Benoit Marty 2023-12-04 16:01:09 +01:00 committed by GitHub
commit 002fd0be66
602 changed files with 5298 additions and 1508 deletions

1
.gitattributes vendored
View file

@ -1,2 +1,3 @@
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text **/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text
libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text

View file

@ -33,12 +33,12 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK

View file

@ -33,7 +33,7 @@ jobs:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- uses: actions/setup-java@v3 - uses: actions/setup-java@v4
name: Use JDK 17 name: Use JDK 17
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options

View file

@ -18,7 +18,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'

View file

@ -21,7 +21,7 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@v1.2.2 uses: nschloe/action-cached-lfs-checkout@v1.2.2
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
@ -57,12 +57,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -35,12 +35,12 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite - name: Run code quality check suite

View file

@ -33,13 +33,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 17 - name: ☕️ Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -20,12 +20,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -27,12 +27,12 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar

View file

@ -39,12 +39,12 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: ☕️ Use JDK 17 - name: ☕️ Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.9.0 uses: gradle/gradle-build-action@v2.10.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -52,7 +52,7 @@ class MainNode(
override val daggerComponent = (context as DaggerComponentOwner).daggerComponent override val daggerComponent = (context as DaggerComponentOwner).daggerComponent
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
return createNode<RootFlowNode>(context = buildContext) return createNode<RootFlowNode>(buildContext = buildContext)
} }
@Composable @Composable

View file

@ -256,6 +256,10 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : RoomLoadedFlowNode.Callback { val callback = object : RoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } coroutineScope.launch { attachRoom(roomId) }
} }

View file

@ -74,6 +74,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner { ), DaggerComponentOwner {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
} }
@ -134,6 +135,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
override fun onOpenGlobalNotificationSettings() { override fun onOpenGlobalNotificationSettings() {
callbacks.forEach { it.onOpenGlobalNotificationSettings() } callbacks.forEach { it.onOpenGlobalNotificationSettings() }
} }
override fun onOpenRoom(roomId: RoomId) {
callbacks.forEach { it.onOpenRoom(roomId) }
}
} }
return roomDetailsEntryPoint.nodeBuilder(this, buildContext) return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
.params(RoomDetailsEntryPoint.Params(initialTarget)) .params(RoomDetailsEntryPoint.Params(initialTarget))

View file

@ -236,11 +236,11 @@ koverMerged {
name = "Global minimum code coverage." name = "Global minimum code coverage."
target = kotlinx.kover.api.VerificationTarget.ALL target = kotlinx.kover.api.VerificationTarget.ALL
bound { bound {
minValue = 60 minValue = 65
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue. // Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update // For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
// minValue to 25 and maxValue to 35. // minValue to 25 and maxValue to 35.
maxValue = 70 maxValue = 75
counter = kotlinx.kover.api.CounterType.INSTRUCTION counter = kotlinx.kover.api.CounterType.INSTRUCTION
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
} }
@ -376,3 +376,22 @@ subprojects {
} }
} }
} }
subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
)
}
if (project.findProperty("composeCompilerMetrics") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
)
}
}
}
}

View file

@ -0,0 +1 @@
Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable.

View file

@ -0,0 +1 @@
Set a default power level to join calls. Also, create new rooms taking this power level into account.

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

@ -0,0 +1 @@
Display different notifications for mentions.

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

@ -0,0 +1 @@
Scroll to end of timeline when sending a new message.

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

@ -0,0 +1 @@
Confirm back navigation when editing a poll only if the poll was changed

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

@ -0,0 +1 @@
Add option to delete a poll while editing the poll

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

@ -0,0 +1 @@
Open room member avatar when you click on it inside the member details screen.

1
changelog.d/1912.bugfix Normal file
View file

@ -0,0 +1 @@
Use the right avatar for DMs in DM rooms

1
changelog.d/1920.misc Normal file
View file

@ -0,0 +1 @@
RoomList: introduce incremental loading to improve performances.

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.api
import androidx.compose.runtime.MutableState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
interface StartDMAction {
/**
* Try to find an existing DM with the given user, or create one if none exists.
* @param userId The user to start a DM with.
* @param actionState The state to update with the result of the action.
*/
suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>)
}

View file

@ -67,6 +67,7 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -77,11 +77,11 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
backstack.push(NavTarget.ConfigureRoom) backstack.push(NavTarget.ConfigureRoom)
} }
} }
createNode<AddPeopleNode>(context = buildContext, plugins = listOf(callback)) createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
} }
NavTarget.ConfigureRoom -> { NavTarget.ConfigureRoom -> {
val callbacks = plugins<ConfigureRoomNode.Callback>() val callbacks = plugins<ConfigureRoomNode.Callback>()
createNode<ConfigureRoomNode>(context = buildContext, plugins = callbacks) createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
} }
} }
} }

View file

@ -72,7 +72,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) } plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
} }
} }
createNode<CreateRoomRootNode>(context = buildContext, plugins = listOf(callback)) createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(callback))
} }
NavTarget.NewRoom -> { NavTarget.NewRoom -> {
val callback = object : ConfigureRoomNode.Callback { val callback = object : ConfigureRoomNode.Callback {
@ -80,7 +80,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) } plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
} }
} }
createNode<ConfigureRoomFlowNode>(context = buildContext, plugins = listOf(callback)) createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
} }
} }
} }

View file

@ -0,0 +1,53 @@
/*
* 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.createroom.impl
import androidx.compose.runtime.MutableState
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.di.SessionScope
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.UserId
import io.element.android.libraries.matrix.api.room.StartDMResult
import io.element.android.libraries.matrix.api.room.startDM
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultStartDMAction @Inject constructor(
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
) : StartDMAction {
override suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>) {
actionState.value = Async.Loading()
when (val result = matrixClient.startDM(userId)) {
is StartDMResult.Success -> {
if (result.isNew) {
analyticsService.capture(CreatedRoom(isDM = true))
}
actionState.value = Async.Success(result.roomId)
}
is StartDMResult.Failure -> {
actionState.value = Async.Failure(result.throwable)
}
}
}
}

View file

@ -21,21 +21,16 @@ import androidx.compose.runtime.MutableState
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 androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
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.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
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.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -43,8 +38,7 @@ class CreateRoomRootPresenter @Inject constructor(
presenterFactory: UserListPresenter.Factory, presenterFactory: UserListPresenter.Factory,
userRepository: UserRepository, userRepository: UserRepository,
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient, private val startDMAction: StartDMAction,
private val analyticsService: AnalyticsService,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
@ -61,37 +55,22 @@ class CreateRoomRootPresenter @Inject constructor(
val userListState = presenter.present() val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } val startDmActionState: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction) is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized startDMAction.execute(event.matrixUser.userId, startDmActionState)
}
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = Async.Uninitialized
} }
} }
return CreateRoomRootState( return CreateRoomRootState(
applicationName = buildMeta.applicationName, applicationName = buildMeta.applicationName,
userListState = userListState, userListState = userListState,
startDmAction = startDmAction.value, startDmAction = startDmActionState.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
suspend {
matrixClient.findDM(matrixUser.userId).use { existingDM ->
existingDM?.roomId ?: createDM(matrixUser)
}
}.runCatchingUpdatingState(startDmAction)
}
private suspend fun createDM(user: MatrixUser): RoomId {
return matrixClient
.createDM(user.userId)
.onSuccess {
analyticsService.capture(CreatedRoom(isDM = true))
}
.getOrThrow()
}
} }

View file

@ -0,0 +1,82 @@
/*
* 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.createroom.impl
import androidx.compose.runtime.mutableStateOf
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultStartDMActionTests {
@Test
fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(A_ROOM_ID)
}
val action = createStartDMAction(matrixClient)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Success(A_ROOM_ID))
}
@Test
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null)
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
}
@Test
fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null)
givenCreateDmResult(Result.failure(A_THROWABLE))
}
val action = createStartDMAction(matrixClient)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Failure<RoomId>(A_THROWABLE))
}
private fun createStartDMAction(
matrixClient: MatrixClient = FakeMatrixClient(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
): DefaultStartDMAction {
return DefaultStartDMAction(
matrixClient = matrixClient,
analyticsService = analyticsService,
)
}
}

View file

@ -20,25 +20,21 @@ import app.cash.molecule.RecompositionMode
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.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -47,142 +43,57 @@ class CreateRoomRootPresenterTests {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakeAnalyticsService: FakeAnalyticsService
@Before
fun setup() {
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
fakeAnalyticsService = FakeAnalyticsService()
userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
userRepository = userRepository,
userListDataStore = UserListDataStore(),
matrixClient = fakeMatrixClient,
analyticsService = fakeAnalyticsService,
buildMeta = aBuildMeta(),
)
}
@Test @Test
fun `present - initial state`() = runTest { fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction()
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty() assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse() assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
}
}
@Test
fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain")) val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain")) val startDMSuccessResult = Async.Success(A_ROOM_ID)
val startDMFailureResult = Async.Failure<RoomId>(A_THROWABLE)
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
@Test
fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isTrue()
}
}
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
fakeMatrixClient.givenFindDmResult(fakeDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
}
}
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmError(A_THROWABLE)
fakeMatrixClient.givenCreateDmResult(createDmResult)
// Failure // Failure
startDMAction.givenExecuteResult(startDMFailureResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem() awaitItem().also { state ->
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java) assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() state.eventSink(CreateRoomRootEvents.CancelStartDM)
}
// Cancel // Success
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM) startDMAction.givenExecuteResult(startDMSuccessResult)
val stateAfterCancel = awaitItem() awaitItem().also { state ->
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.startDmAction).isEqualTo(Async.Uninitialized)
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
// Failure }
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterSecondAttempt = awaitItem() awaitItem().also { state ->
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java) assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() }
// Retry with success
fakeMatrixClient.givenCreateDmError(null)
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterRetryStartDM = awaitItem()
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
} }
} }
private fun createCreateRoomRootPresenter(
startDMAction: StartDMAction = FakeStartDMAction(),
): CreateRoomRootPresenter {
return CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
userRepository = FakeUserRepository(),
userListDataStore = UserListDataStore(),
startDMAction = startDMAction,
buildMeta = aBuildMeta(),
)
}
} }

View file

@ -0,0 +1,31 @@
/*
* 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")
}
android {
namespace = "io.element.android.features.createroom.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
implementation(projects.libraries.architecture)
api(projects.features.createroom.api)
}

View file

@ -0,0 +1,40 @@
/*
* 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.createroom.test
import androidx.compose.runtime.MutableState
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import kotlinx.coroutines.delay
class FakeStartDMAction : StartDMAction {
private var executeResult: Async<RoomId> = Async.Success(A_ROOM_ID)
fun givenExecuteResult(result: Async<RoomId>) {
executeResult = result
}
override suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>) {
actionState.value = Async.Loading()
delay(1)
actionState.value = executeResult
}
}

View file

@ -140,8 +140,7 @@ private fun BackupUploadState.isBackingUp(): Boolean {
return when (this) { return when (this) {
BackupUploadState.Unknown, BackupUploadState.Unknown,
BackupUploadState.Waiting, BackupUploadState.Waiting,
is BackupUploadState.Uploading, is BackupUploadState.Uploading -> true
is BackupUploadState.CheckingIfUploadNeeded -> true
is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection
BackupUploadState.Done, BackupUploadState.Done,
BackupUploadState.Error -> false BackupUploadState.Error -> false

View file

@ -23,6 +23,11 @@ plugins {
android { android {
namespace = "io.element.android.features.messages.impl" namespace = "io.element.android.features.messages.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -48,6 +53,7 @@ dependencies {
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api) implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
@ -87,7 +93,10 @@ dependencies {
testImplementation(projects.libraries.textcomposer.test) testImplementation(projects.libraries.textcomposer.test)
testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.voicerecorder.test)
testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaplayer.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.junitext)
testImplementation(libs.test.robolectric)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -39,8 +39,6 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.forward.ForwardMessagesNode import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -62,6 +60,8 @@ 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
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -180,6 +180,8 @@ class MessagesFlowNode @AssistedInject constructor(
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
canDownload = true,
canShare = true,
) )
createNode<MediaViewerNode>(buildContext, listOf(inputs)) createNode<MediaViewerNode>(buildContext, listOf(inputs))
} }

View file

@ -79,6 +79,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
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.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState import io.element.android.libraries.matrix.ui.room.canRedactAsState
@ -108,6 +109,7 @@ class MessagesPresenter @AssistedInject constructor(
private val featureFlagsService: FeatureFlagService, private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@ -144,6 +146,16 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false) mutableStateOf(false)
} }
var canJoinCall by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(currentSessionIdHolder.current) {
withContext(dispatchers.io) {
canJoinCall = room.canUserJoinCall(userId = currentSessionIdHolder.current).getOrDefault(false)
}
}
val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) } val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) } var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) { LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
@ -162,8 +174,6 @@ class MessagesPresenter @AssistedInject constructor(
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) } var enableVoiceMessages by remember { mutableStateOf(false) }
// TODO add min power level to use this feature in the future?
val enableInRoomCalls = true
LaunchedEffect(featureFlagsService) { LaunchedEffect(featureFlagsService) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages) enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
} }
@ -193,6 +203,12 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
val callState = when {
!canJoinCall -> RoomCallState.DISABLED
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
else -> RoomCallState.ENABLED
}
return MessagesState( return MessagesState(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomName,
@ -213,9 +229,8 @@ class MessagesPresenter @AssistedInject constructor(
inviteProgress = inviteProgress.value, inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting, enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
isCallOngoing = roomInfo?.hasRoomCall ?: false, callState = callState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -224,7 +239,7 @@ class MessagesPresenter @AssistedInject constructor(
return AvatarData( return AvatarData(
id = id, id = id,
name = name, name = name,
url = avatarUrl, url = avatarUrl ?: room.avatarUrl,
size = AvatarSize.TimelineRoom size = AvatarSize.TimelineRoom
) )
} }

View file

@ -51,8 +51,13 @@ data class MessagesState(
val showReinvitePrompt: Boolean, val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean, val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean, val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean, val callState: RoomCallState,
val isCallOngoing: Boolean,
val appName: String, val appName: String,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )
enum class RoomCallState {
ENABLED,
ONGOING,
DISABLED
}

View file

@ -66,7 +66,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
), ),
aMessagesState().copy( aMessagesState().copy(
isCallOngoing = true, callState = RoomCallState.ONGOING,
), ),
aMessagesState().copy( aMessagesState().copy(
enableVoiceMessages = true, enableVoiceMessages = true,
@ -75,6 +75,9 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
showSendFailureDialog = true showSendFailureDialog = true
), ),
), ),
aMessagesState().copy(
callState = RoomCallState.DISABLED,
),
) )
} }
@ -117,8 +120,7 @@ fun aMessagesState() = MessagesState(
showReinvitePrompt = false, showReinvitePrompt = false,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
enableInRoomCalls = true, callState = RoomCallState.ENABLED,
isCallOngoing = false,
appName = "Element", appName = "Element",
eventSink = {} eventSink = {}
) )

View file

@ -191,10 +191,9 @@ fun MessagesView(
MessagesViewTopBar( MessagesViewTopBar(
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(),
inRoomCallsEnabled = state.enableInRoomCalls, callState = state.callState,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked, onRoomDetailsClicked = onRoomDetailsClicked,
isCallOngoing = state.isCallOngoing,
onJoinCallClicked = onJoinCallClicked, onJoinCallClicked = onJoinCallClicked,
) )
} }
@ -449,8 +448,7 @@ private fun MessagesViewComposerBottomSheetContents(
private fun MessagesViewTopBar( private fun MessagesViewTopBar(
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
inRoomCallsEnabled: Boolean, callState: RoomCallState,
isCallOngoing: Boolean,
onRoomDetailsClicked: () -> Unit, onRoomDetailsClicked: () -> Unit,
onJoinCallClicked: () -> Unit, onJoinCallClicked: () -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
@ -477,13 +475,11 @@ private fun MessagesViewTopBar(
} }
}, },
actions = { actions = {
if (inRoomCallsEnabled) { if (callState == RoomCallState.ONGOING) {
if (isCallOngoing) { JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked) } else {
} else { IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) {
IconButton(onClick = onJoinCallClicked) { Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
}
} }
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.attachments
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Immutable @Immutable

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@ -114,6 +115,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState.value = SendActionState.Done sendActionState.value = SendActionState.Done
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment")
if (error is CancellationException) { if (error is CancellationException) {
throw error throw error
} else { } else {

View file

@ -19,10 +19,10 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.features.messages.impl.media.local.aFileInfo import io.element.android.libraries.mediaviewer.api.local.aFileInfo
import io.element.android.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.mediaviewer.api.local.anImageInfo
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>

View file

@ -32,7 +32,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable

View file

@ -34,7 +34,6 @@ import androidx.media3.common.util.UnstableApi
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -51,6 +50,7 @@ import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -432,6 +433,7 @@ class MessageComposerPresenter @Inject constructor(
attachmentState.value = AttachmentsState.None attachmentState.value = AttachmentsState.None
} }
.onFailure { cause -> .onFailure { cause ->
Timber.e(cause, "Failed to send attachment")
attachmentState.value = AttachmentsState.None attachmentState.value = AttachmentsState.None
if (cause is CancellationException) { if (cause is CancellationException) {
throw cause throw cause

View file

@ -34,6 +34,7 @@ import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.PollVote import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
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.model.NewEventState
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.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@ -98,7 +99,7 @@ class TimelinePresenter @AssistedInject constructor(
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val hasNewItems = remember { mutableStateOf(false) } val newItemState = remember { mutableStateOf(NewEventState.None) }
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState() val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState() val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
@ -121,7 +122,7 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> { is TimelineEvents.OnScrollFinished -> {
if (event.firstIndex == 0) { if (event.firstIndex == 0) {
hasNewItems.value = false newItemState.value = NewEventState.None
} }
appScope.sendReadReceiptIfNeeded( appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
@ -150,7 +151,7 @@ class TimelinePresenter @AssistedInject constructor(
} }
LaunchedEffect(timelineItems.size) { LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems) computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -182,7 +183,7 @@ class TimelinePresenter @AssistedInject constructor(
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled, showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value, newEventState = newItemState.value,
sessionState = sessionState, sessionState = sessionState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
@ -191,22 +192,32 @@ class TimelinePresenter @AssistedInject constructor(
/** /**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes. * This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items. * Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
* The state never goes back to false from this method, but need to be reset from somewhere else. * The state never goes back to None from this method, but need to be reset from somewhere else.
*/ */
private suspend fun computeHasNewItems( private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>, timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>, prevMostRecentItemId: MutableState<String?>,
hasNewItemsState: MutableState<Boolean> newEventState: MutableState<NewEventState>
) = withContext(dispatchers.computation) { ) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) {
return@withContext
}
val newMostRecentItem = timelineItems.firstOrNull() val newMostRecentItem = timelineItems.firstOrNull()
val prevMostRecentItemIdValue = prevMostRecentItemId.value val prevMostRecentItemIdValue = prevMostRecentItemId.value
val newMostRecentItemId = newMostRecentItem?.identifier() val newMostRecentItemId = newMostRecentItem?.identifier()
val hasNewItems = prevMostRecentItemIdValue != null && val hasNewEvent = prevMostRecentItemIdValue != null &&
newMostRecentItem is TimelineItem.Event && newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewItems) { if (hasNewEvent) {
hasNewItemsState.value = true val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
val fromMe = newMostRecentEvent?.localSendState != null
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
NewEventState.FromOther
}
} }
prevMostRecentItemId.value = newMostRecentItemId prevMostRecentItemId.value = newMostRecentItemId
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
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.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -30,7 +31,7 @@ data class TimelineState(
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean, val newEventState: NewEventState,
val sessionState: SessionState, val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit
) )

View file

@ -16,7 +16,9 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
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.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
@ -53,7 +55,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
), ),
highlightedEventId = null, highlightedEventId = null,
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
hasNewItems = false, newEventState = NewEventState.None,
sessionState = aSessionState( sessionState = aSessionState(
isSessionVerified = true, isSessionVerified = true,
isKeyBackupEnabled = true, isKeyBackupEnabled = true,
@ -186,17 +188,27 @@ internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
) )
} }
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { internal fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent( val event1 = aTimelineItemEvent(
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
),
) )
val event2 = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(body = "Another state event"),
groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(1)).toPersistentList(),
),
)
val events = listOf(event1, event2)
return TimelineItem.GroupedEvents( return TimelineItem.GroupedEvents(
id = id.toString(), id = id.toString(),
events = listOf( events = events.toImmutableList(),
event, aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
event,
).toImmutableList()
) )
} }

View file

@ -20,13 +20,11 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
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.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -42,36 +40,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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 io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
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.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.NewEventState
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.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.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.designsystem.animation.alphaAnimation import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -167,141 +156,44 @@ fun TimelineView(
TimelineScrollHelper( TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(), isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState, lazyListState = lazyListState,
hasNewItems = state.hasNewItems, newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt onScrollFinishedAt = ::onScrollFinishedAt
) )
} }
} }
@Composable
private fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
sessionState = sessionState,
modifier = modifier,
)
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value
}
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = isExpanded.value,
isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick,
)
if (isExpanded.value) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
}
}
}
}
}
}
}
@Composable @Composable
private fun BoxScope.TimelineScrollHelper( private fun BoxScope.TimelineScrollHelper(
isTimelineEmpty: Boolean, isTimelineEmpty: Boolean,
lazyListState: LazyListState, lazyListState: LazyListState,
hasNewItems: Boolean, newEventState: NewEventState,
onScrollFinishedAt: (Int) -> Unit, onScrollFinishedAt: (Int) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } } val canAutoScroll by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3
}
}
LaunchedEffect(canAutoScroll, hasNewItems) { fun scrollToBottom() {
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems coroutineScope.launch {
if (shouldAutoScroll) { if (lazyListState.firstVisibleItemIndex > 10) {
coroutineScope.launch { lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0) lazyListState.animateScrollToItem(0)
} }
} }
} }
LaunchedEffect(canAutoScroll, newEventState) {
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldAutoScroll) {
scrollToBottom()
}
}
LaunchedEffect(isScrollFinished, isTimelineEmpty) { LaunchedEffect(isScrollFinished, isTimelineEmpty) {
if (isScrollFinished && !isTimelineEmpty) { if (isScrollFinished && !isTimelineEmpty) {
// Notify the parent composable about the first visible item index when scrolling finishes // Notify the parent composable about the first visible item index when scrolling finishes
@ -315,15 +207,7 @@ private fun BoxScope.TimelineScrollHelper(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = { onClick = ::scrollToBottom,
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
) )
} }

View file

@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme
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.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@ -66,6 +67,7 @@ import io.element.android.features.messages.impl.timeline.components.event.toExt
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.InReplyToMetadata
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.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -75,6 +77,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -89,18 +92,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
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 io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -171,8 +163,6 @@ fun TimelineItemEventRow(
state = state.draggableState, state = state.draggableState,
), ),
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -183,7 +173,6 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -191,8 +180,6 @@ fun TimelineItemEventRow(
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -203,10 +190,20 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
} }
} }
@ -236,8 +233,6 @@ private fun SwipeSensitivity(
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
@ -246,7 +241,6 @@ private fun TimelineItemEventRowContent(
inReplyToClicked: () -> Unit, inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit, onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReadReceiptsClicked: () -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@ -267,7 +261,6 @@ private fun TimelineItemEventRowContent(
sender, sender,
message, message,
reactions, reactions,
readReceipts,
) = createRefs() ) = createRefs()
// Sender // Sender
@ -334,25 +327,6 @@ private fun TimelineItemEventRowContent(
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
) )
} }
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = onReadReceiptsClicked,
modifier = Modifier
.constrainAs(readReceipts) {
if (event.reactionsState.reactions.isNotEmpty()) {
top.linkTo(reactions.bottom, margin = 4.dp)
} else {
top.linkTo(message.bottom, margin = 4.dp)
}
}
)
} }
} }
@ -538,13 +512,10 @@ private fun MessageEventBubbleContent(
} }
val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
val text = textForInReplyTo(inReplyTo)
val topPadding = if (showThreadDecoration) 0.dp else 8.dp val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent( ReplyToContent(
senderName = senderName, senderName = senderName,
text = text, metadata = inReplyTo.metadata(),
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
@ -585,11 +556,10 @@ private fun MessageEventBubbleContent(
@Composable @Composable
private fun ReplyToContent( private fun ReplyToContent(
senderName: String, senderName: String,
text: String?, metadata: InReplyToMetadata?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val paddings = if (attachmentThumbnailInfo != null) { val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else { } else {
PaddingValues(horizontal = 12.dp, vertical = 4.dp) PaddingValues(horizontal = 12.dp, vertical = 4.dp)
@ -599,9 +569,9 @@ private fun ReplyToContent(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(paddings) .padding(paddings)
) { ) {
if (attachmentThumbnailInfo != null) { if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail( AttachmentThumbnail(
info = attachmentThumbnailInfo, info = metadata.attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant, backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
@ -619,7 +589,7 @@ private fun ReplyToContent(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = text.orEmpty(), text = metadata?.text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary, color = ElementTheme.materialColors.secondary,
@ -630,60 +600,6 @@ private fun ReplyToContent(
} }
} }
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
is LocationMessageType -> AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Location,
)
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
is VoiceMessageType -> AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Voice,
)
else -> null
}
is PollContent -> AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
else -> null
}
}
@Composable
private fun textForInReplyTo(inReplyTo: InReplyToDetails): String {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (eventContent.type) {
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
else -> inReplyTo.textContent ?: eventContent.body
}
is PollContent -> eventContent.question
else -> ""
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemEventRowPreview() = ElementPreview { internal fun TimelineItemEventRowPreview() = ElementPreview {

View file

@ -45,6 +45,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@ -127,9 +129,9 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
question = "Poll which are being replied.", question = "Poll which are being replied.",
kind = PollKind.Disclosed, kind = PollKind.Disclosed,
maxSelections = 1u, maxSelections = 1u,
answers = emptyList(), answers = persistentListOf(),
votes = emptyMap(), votes = persistentMapOf(),
endTime = null, endTime = null
isEdited = false, isEdited = false,
), ),
).map { ).map {

View file

@ -0,0 +1,203 @@
/*
* 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.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Composable
fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value
}
TimelineItemGroupedEventsRowContent(
isExpanded = isExpanded.value,
onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem,
highlightedItem = highlightedItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
)
}
@Composable
private fun TimelineItemGroupedEventsRowContent(
isExpanded: Boolean,
onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents,
highlightedItem: String?,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = isExpanded,
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = onExpandGroupClick,
)
if (isExpanded) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
}
}
} else if (showReadReceipts) {
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = null,
isLastOutgoingMessage = false,
receipts = timelineItem.aggregatedReadReceipts,
),
showReadReceipts = true,
onReadReceiptsClicked = onExpandGroupClick
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
TimelineItemGroupedEventsRowContent(
isExpanded = true,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(),
highlightedItem = null,
showReadReceipts = true,
isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPreview {
TimelineItemGroupedEventsRowContent(
isExpanded = false,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(),
highlightedItem = null,
showReadReceipts = true,
isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}

View file

@ -0,0 +1,114 @@
/*
* 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.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Composable
internal fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
sessionState = sessionState,
modifier = modifier,
)
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow(
timelineItem = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
)
}
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components
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.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@ -32,51 +33,73 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
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.event.noExtraPadding import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
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.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.toPersistentList
@Composable @Composable
fun TimelineItemStateEventRow( fun TimelineItemStateEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Box( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp)
.wrapContentHeight(),
contentAlignment = Alignment.Center
) { ) {
MessageStateEventContainer( Box(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier modifier = Modifier
.zIndex(-1f) .fillMaxWidth()
.widthIn(max = 320.dp) .padding(top = 8.dp, bottom = 2.dp)
.wrapContentHeight(),
contentAlignment = Alignment.Center
) { ) {
TimelineItemEventContentView( MessageStateEventContainer(
content = event.content, isHighlighted = isHighlighted,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
extraPadding = noExtraPadding, modifier = Modifier
eventSink = eventSink, .zIndex(-1f)
modifier = Modifier.defaultTimelineContentPadding() .widthIn(max = 320.dp)
) ) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding,
eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding()
)
}
} }
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = { onReadReceiptsClick(event) },
)
} }
} }
@ -87,11 +110,17 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
event = aTimelineItemEvent( event = aTimelineItemEvent(
isMine = false, isMine = false,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
)
), ),
showReadReceipts = true,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onReadReceiptsClick = {},
eventSink = {} eventSink = {}
) )
} }

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.timeline.components.group package io.element.android.features.messages.impl.timeline.components.group
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -76,9 +79,17 @@ fun GroupHeaderView(
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
) )
val rotation: Float by animateFloatAsState(
targetValue = if (isExpanded) 90f else 0f,
animationSpec = tween(
delayMillis = 0,
durationMillis = 300,
),
label = "chevron"
)
Icon( Icon(
modifier = Modifier.rotate(if (isExpanded) 180f else 0f), modifier = Modifier.rotate(rotation),
imageVector = CompoundIcons.ChevronDown, imageVector = CompoundIcons.ChevronRight,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.secondary tint = MaterialTheme.colorScheme.secondary
) )

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.reactionsummary package io.element.android.features.messages.impl.timeline.components.reactionsummary
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -38,10 +37,6 @@ class ReactionSummaryPresenter @Inject constructor(
) : Presenter<ReactionSummaryState> { ) : Presenter<ReactionSummaryState> {
@Composable @Composable
override fun present(): ReactionSummaryState { override fun present(): ReactionSummaryState {
LaunchedEffect(Unit) {
room.updateMembers()
}
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val target: MutableState<ReactionSummaryState.Summary?> = remember { val target: MutableState<ReactionSummaryState.Summary?> = remember {

View file

@ -33,23 +33,23 @@ class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewSta
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } }, receipts = List(1) { aReadReceiptData(it) },
), ),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } }, receipts = List(2) { aReadReceiptData(it) },
), ),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } }, receipts = List(3) { aReadReceiptData(it) },
), ),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } }, receipts = List(4) { aReadReceiptData(it) },
), ),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } }, receipts = List(5) { aReadReceiptData(it) },
), ),
) )
} }

View file

@ -18,9 +18,10 @@ package io.element.android.features.messages.impl.timeline.components.receipt.bo
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -29,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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 io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -38,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -83,34 +84,39 @@ internal fun ReadReceiptBottomSheet(
} }
@Composable @Composable
private fun ColumnScope.ReadReceiptBottomSheetContent( private fun ReadReceiptBottomSheetContent(
state: ReadReceiptBottomSheetState, state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
) { ) {
ListItem( LazyColumn {
headlineContent = { item {
Text(text = stringResource(id = CommonStrings.common_seen_by)) ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
}
items(
items = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
) {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
} }
)
val receipts = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
receipts.forEach {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
} }
} }

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -45,14 +44,15 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.time.Duration
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
class TimelineItemContentMessageFactory @Inject constructor( class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter, private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor, private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) { ) {
@ -104,7 +104,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration?.toMillis() ?: 0L, duration = messageType.info?.duration ?: Duration.ZERO,
blurHash = messageType.info?.blurhash, blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),

View file

@ -73,7 +73,8 @@ private fun MutableList<TimelineItem>.addGroup(
add( add(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
id = groupId, id = groupId,
events = groupOfItems.toImmutableList() events = groupOfItems.toImmutableList(),
aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList()
) )
) )
} }

View file

@ -0,0 +1,108 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
val text: String?
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
override val text: String? = attachmentThumbnailInfo.textContent
}
data class Text(
override val text: String
) : InReplyToMetadata
}
/**
* Computes metadata for the in reply to message.
*
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
)
is VideoMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
)
is FileMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
)
is LocationMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_shared_location),
type = AttachmentThumbnailType.Location,
)
)
is AudioMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
)
is VoiceMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_voice_message),
type = AttachmentThumbnailType.Voice,
)
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
}
is PollContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
)
else -> null
}

View file

@ -16,13 +16,10 @@
package io.element.android.features.messages.impl.timeline.model package io.element.android.features.messages.impl.timeline.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider /**
* Model if there is a new event in the timeline and if it is from me or from other.
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<TimelineItemGroupPosition> { * This can be used to scroll to the bottom of the list when a new event is added.
override val values = sequenceOf( */
TimelineItemGroupPosition.First, enum class NewEventState {
TimelineItemGroupPosition.Middle, None, FromMe, FromOther
TimelineItemGroupPosition.Last,
TimelineItemGroupPosition.None,
)
} }

View file

@ -88,6 +88,6 @@ sealed interface TimelineItem {
data class GroupedEvents( data class GroupedEvents(
val id: String, val id: String,
val events: ImmutableList<Event>, val events: ImmutableList<Event>,
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem ) : TimelineItem
} }

View file

@ -16,9 +16,8 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import java.time.Duration import kotlin.time.Duration
data class TimelineItemAudioContent( data class TimelineItemAudioContent(
val body: String, val body: String,
@ -29,6 +28,10 @@ data class TimelineItemAudioContent(
val fileExtension: String, val fileExtension: String,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize) val fileExtensionAndSize =
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
fileExtension,
formattedFileSize
)
override val type: String = "TimelineItemAudioContent" override val type: String = "TimelineItemAudioContent"
} }

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import java.time.Duration import kotlin.time.Duration.Companion.milliseconds
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> { open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
override val values: Sequence<TimelineItemAudioContent> override val values: Sequence<TimelineItemAudioContent>
@ -35,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu
mimeType = MimeTypes.Pdf, mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB", formattedFileSize = "100kB",
fileExtension = "mp3", fileExtension = "mp3",
duration = Duration.ofMillis(100), duration = 100.milliseconds,
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
) )

View file

@ -79,6 +79,8 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent( fun aTimelineItemStateEventContent(
body = "A state event", body: String = "A state event",
) = TimelineItemStateEventContent(
body = body,
) )

View file

@ -16,8 +16,8 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
data class TimelineItemFileContent( data class TimelineItemFileContent(
val body: String, val body: String,

View file

@ -17,10 +17,11 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.time.Duration
data class TimelineItemVideoContent( data class TimelineItemVideoContent(
val body: String, val body: String,
val duration: Long, val duration: Duration,
val videoSource: MediaSource, val videoSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val aspectRatio: Float?, val aspectRatio: Float?,

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import kotlin.time.Duration.Companion.milliseconds
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> { open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
override val values: Sequence<TimelineItemVideoContent> override val values: Sequence<TimelineItemVideoContent>
@ -35,7 +36,7 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
thumbnailSource = null, thumbnailSource = null,
blurHash = A_BLUR_HASH, blurHash = A_BLUR_HASH,
aspectRatio = 0.5f, aspectRatio = 0.5f,
duration = 100, duration = 100.milliseconds,
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300, height = 300,
width = 150, width = 150,

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
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.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import java.time.Duration import kotlin.time.Duration
data class TimelineItemVoiceContent( data class TimelineItemVoiceContent(
val eventId: EventId?, val eventId: EventId?,

View file

@ -21,21 +21,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes
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.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import java.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> { open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
override val values: Sequence<TimelineItemVoiceContent> override val values: Sequence<TimelineItemVoiceContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemVoiceContent( aTimelineItemVoiceContent(
durationMs = 1, duration = 1.milliseconds,
waveform = listOf(), waveform = listOf(),
), ),
aTimelineItemVoiceContent( aTimelineItemVoiceContent(
durationMs = 10_000, duration = 10_000.milliseconds,
waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f), waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
), ),
aTimelineItemVoiceContent( aTimelineItemVoiceContent(
durationMs = 1_800_000, // 30 minutes duration = 30.minutes,
waveform = List(1024) { it / 1024f }, waveform = List(1024) { it / 1024f },
), ),
) )
@ -44,14 +46,14 @@ open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVoiceContent( fun aTimelineItemVoiceContent(
eventId: String? = "\$anEventId", eventId: String? = "\$anEventId",
body: String = "body doesn't really matter for a voice message", body: String = "body doesn't really matter for a voice message",
durationMs: Long = 61_000, duration: Duration = 61_000.milliseconds,
contentUri: String = "mxc://matrix.org/1234567890abcdefg", contentUri: String = "mxc://matrix.org/1234567890abcdefg",
mimeType: String = MimeTypes.Ogg, mimeType: String = MimeTypes.Ogg,
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f), waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
) = TimelineItemVoiceContent( ) = TimelineItemVoiceContent(
eventId = eventId?.let { EventId(it) }, eventId = eventId?.let { EventId(it) },
body = body, body = body,
duration = Duration.ofMillis(durationMs), duration = duration,
mediaSource = MediaSource(contentUri), mediaSource = MediaSource(contentUri),
mimeType = mimeType, mimeType = mimeType,
waveform = waveform.toPersistentList(), waveform = waveform.toPersistentList(),

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.impl.voicemessages.composer
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -41,7 +42,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
) { ) {
companion object { companion object {
const val MIME_TYPE = "audio/ogg" const val MIME_TYPE = MimeTypes.Ogg
} }
private var mediaPath: String? = null private var mediaPath: String? = null
@ -201,6 +202,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
progress = 0f, progress = 0f,
) )
} }
/** /**
* Whether this player is currently playing. * Whether this player is currently playing.
*/ */
@ -212,7 +214,6 @@ class VoiceMessageComposerPlayer @Inject constructor(
val isStopped get() = this.playState == PlayState.Stopped val isStopped get() = this.playState == PlayState.Stopped
} }
enum class PlayState { enum class PlayState {
/** /**
* The player is stopped, i.e. it has just been initialised. * The player is stopped, i.e. it has just been initialised.

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
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.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
@ -196,7 +197,7 @@ class DefaultVoiceMessagePlayer(
mediaPlayer.setMedia( mediaPlayer.setMedia(
uri = mediaFile.path, uri = mediaFile.path,
mediaId = eventId.value, mediaId = eventId.value,
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually. mimeType = MimeTypes.Ogg, // Files in the voice cache have no extension so we need to set the mime type manually.
startPositionMs = if (state.isEnded) 0L else state.currentPosition, startPositionMs = if (state.isEnded) 0L else state.currentPosition,
) )
} }

View file

@ -98,7 +98,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
} }
} }
val duration by remember { val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.toMillis() } derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
} }
val progress by remember { val progress by remember {
derivedStateOf { derivedStateOf {

View file

@ -45,6 +45,7 @@
<string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string> <string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string> <string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string> <string name="screen_room_reactions_show_less">"Zobrazit méně"</string>

View file

@ -44,6 +44,7 @@
<string name="screen_room_notification_settings_error_loading_settings">"Une erreur sest produite lors du chargement des paramètres de notification."</string> <string name="screen_room_notification_settings_error_loading_settings">"Une erreur sest produite lors du chargement des paramètres de notification."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Votre serveur daccueil ne supporte pas cette option pour les salons chiffrés, vous ne serez pas notifié(e) dans ce salon."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string> <string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
<string name="screen_room_reactions_show_less">"Afficher moins"</string> <string name="screen_room_reactions_show_less">"Afficher moins"</string>

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
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.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
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.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
@ -80,6 +79,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@ -92,12 +92,14 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class MessagesPresenterTest { class MessagesPresenterTest {
@get:Rule @get:Rule
@ -125,6 +127,21 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanUserJoinCall(Result.success(false))
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
}
}
@Test @Test
fun `present - handle toggling a reaction`() = runTest { fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@ -264,7 +281,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent( content = TimelineItemVideoContent(
body = "video.mp4", body = "video.mp4",
duration = 10L, duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL), videoSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL), thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4, mimeType = MimeTypes.Mp4,
@ -462,7 +479,7 @@ class MessagesPresenterTest {
val room = FakeMatrixRoom(sessionId = A_SESSION_ID) val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
listOf( persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
) )
@ -489,7 +506,7 @@ class MessagesPresenterTest {
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Error( MatrixRoomMembersState.Error(
failure = Throwable(), failure = Throwable(),
prevRoomMembers = listOf( prevRoomMembers = persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
) )
@ -535,7 +552,7 @@ class MessagesPresenterTest {
val room = FakeMatrixRoom(sessionId = A_SESSION_ID) val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
listOf( persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
) )
@ -655,6 +672,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
currentSessionIdHolder: CurrentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
): MessagesPresenter { ): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
@ -723,6 +741,7 @@ class MessagesPresenterTest {
featureFlagsService = FakeFeatureFlagService(), featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder,
) )
} }
} }

View file

@ -26,13 +26,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aLocalMedia
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -16,22 +16,10 @@
package io.element.android.features.messages.impl.fixtures package io.element.android.features.messages.impl.fixtures
import android.net.Uri
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
fun aLocalMedia(
uri: Uri,
mediaInfo: MediaInfo = anImageInfo(),
) = LocalMedia(
uri = uri,
info = mediaInfo
)
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
localMedia = localMedia, localMedia = localMedia,
compressIfPossible = compressIfPossible, compressIfPossible = compressIfPossible,
) )

View file

@ -32,7 +32,6 @@ 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.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -40,6 +39,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope

View file

@ -26,7 +26,6 @@ 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 im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestion
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.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
@ -67,6 +66,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@ -78,11 +78,11 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.internal.immutableListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import uniffi.wysiwyg_composer.MentionsState import uniffi.wysiwyg_composer.MentionsState
@ -734,7 +734,7 @@ class MessageComposerPresenterTest {
isOneToOne = false, isOneToOne = false,
).apply { ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready( givenRoomMembersState(MatrixRoomMembersState.Ready(
immutableListOf(currentUser, invitedUser, bob, david), persistentListOf(currentUser, invitedUser, bob, david),
)) ))
givenCanTriggerRoomNotification(Result.success(true)) givenCanTriggerRoomNotification(Result.success(true))
} }
@ -798,7 +798,7 @@ class MessageComposerPresenterTest {
isOneToOne = true, isOneToOne = true,
).apply { ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready( givenRoomMembersState(MatrixRoomMembersState.Ready(
immutableListOf(currentUser, invitedUser, bob, david), persistentListOf(currentUser, invitedUser, bob, david),
)) ))
givenCanTriggerRoomNotification(Result.success(true)) givenCanTriggerRoomNotification(Result.success(true))
} }

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
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.model.NewEventState
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.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
@ -36,6 +37,7 @@ 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
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -48,9 +50,12 @@ import io.element.android.libraries.matrix.test.verification.FakeSessionVerifica
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.awaitWithLatch import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -187,28 +192,49 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - covers hasNewItems scenarios`() = runTest { fun `present - covers newEventState scenarios`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline()
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse() assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0) assertThat(initialState.timelineItems.size).isEqualTo(0)
timeline.updateTimelineItems { timeline.updateTimelineItems {
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent()))) listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
} }
skipItems(1) consumeItemsUntilPredicate { it.timelineItems.size == 1 }
assertThat(awaitItem().timelineItems.size).isEqualTo(1) // Mimics sending a message, and assert newEventState is FromMe
timeline.updateTimelineItems { items -> timeline.updateTimelineItems { items ->
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent()))) val event = anEventTimelineItem(content = aMessageContent(), localSendState = LocalEventSendState.Sent(AN_EVENT_ID))
items + listOf(MatrixTimelineItem.Event(1, event))
} }
skipItems(1) consumeItemsUntilPredicate { it.timelineItems.size == 2 }
assertThat(awaitItem().timelineItems.size).isEqualTo(2) awaitLastSequentialItem().also { state ->
assertThat(awaitItem().hasNewItems).isTrue() assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
}
// Mimics receiving a message without clearing the previous FromMe
timeline.updateTimelineItems { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event(2, event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 3 }
// Scroll to bottom to clear previous FromMe
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
assertThat(awaitItem().hasNewItems).isFalse() awaitLastSequentialItem().also { state ->
assertThat(state.newEventState).isEqualTo(NewEventState.None)
}
// Mimics receiving a message and assert newEventState is FromOther
timeline.updateTimelineItems { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event(3, event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
awaitLastSequentialItem().also { state ->
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
}
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -221,7 +247,7 @@ class TimelinePresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse() assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0) assertThat(initialState.timelineItems.size).isEqualTo(0)
val now = Date().time val now = Date().time
val minuteInMillis = 60 * 1000 val minuteInMillis = 60 * 1000
@ -229,18 +255,18 @@ class TimelinePresenterTest {
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user -> val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis) ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
} }
val oneReaction = listOf( val oneReaction = persistentListOf(
EventReaction( EventReaction(
key = "❤️", key = "❤️",
senders = listOf(alice, charlie) senders = persistentListOf(alice, charlie)
), ),
EventReaction( EventReaction(
key = "👍", key = "👍",
senders = listOf(alice, bob) senders = persistentListOf(alice, bob)
), ),
EventReaction( EventReaction(
key = "🐶", key = "🐶",
senders = listOf(charlie) senders = persistentListOf(charlie)
), ),
) )
timeline.updateTimelineItems { timeline.updateTimelineItems {

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
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.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -42,7 +43,7 @@ class ReactionSummaryPresenterTests {
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME) private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key) private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
private val room = FakeMatrixRoom().apply { private val room = FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
} }
private val presenter = ReactionSummaryPresenter(room) private val presenter = ReactionSummaryPresenter(room)

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -55,11 +54,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
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.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.time.Duration import kotlin.time.Duration
import java.time.Duration.ofMinutes import kotlin.time.Duration.Companion.minutes
class TimelineItemContentMessageFactoryTest { class TimelineItemContentMessageFactoryTest {
@ -140,14 +141,14 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
body = "body", body = "body",
duration = 0, duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null), videoSource = MediaSource(url = "url", json = null),
thumbnailSource = null, thumbnailSource = null,
aspectRatio = null, aspectRatio = null,
blurHash = null, blurHash = null,
height = null, height = null,
width = null, width = null,
mimeType = "application/octet-stream", mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
fileExtension = "", fileExtension = "",
) )
@ -163,7 +164,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body.mp4", body = "body.mp4",
source = MediaSource("url"), source = MediaSource("url"),
info = VideoInfo( info = VideoInfo(
duration = ofMinutes(1), duration = 1.minutes,
height = 100, height = 100,
width = 300, width = 300,
mimetype = MimeTypes.Mp4, mimetype = MimeTypes.Mp4,
@ -184,7 +185,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
body = "body.mp4", body = "body.mp4",
duration = 60_000, duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null), videoSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"), thumbnailSource = MediaSource("url_thumbnail"),
aspectRatio = 3f, aspectRatio = 3f,
@ -210,7 +211,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body", body = "body",
duration = Duration.ZERO, duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream", mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
fileExtension = "", fileExtension = "",
) )
@ -226,7 +227,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body.mp3", body = "body.mp3",
source = MediaSource("url"), source = MediaSource("url"),
info = AudioInfo( info = AudioInfo(
duration = ofMinutes(1), duration = 1.minutes,
size = 123L, size = 123L,
mimetype = MimeTypes.Mp3, mimetype = MimeTypes.Mp3,
) )
@ -237,7 +238,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
body = "body.mp3", body = "body.mp3",
duration = ofMinutes(1), duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Mp3, mimeType = MimeTypes.Mp3,
formattedFileSize = "123 Bytes", formattedFileSize = "123 Bytes",
@ -259,7 +260,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body", body = "body",
duration = Duration.ZERO, duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream", mimeType = MimeTypes.OctetStream,
waveform = emptyList<Float>().toImmutableList() waveform = emptyList<Float>().toImmutableList()
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@ -274,12 +275,13 @@ class TimelineItemContentMessageFactoryTest {
body = "body.ogg", body = "body.ogg",
source = MediaSource("url"), source = MediaSource("url"),
info = AudioInfo( info = AudioInfo(
duration = ofMinutes(1), duration = 1.minutes,
size = 123L, size = 123L,
mimetype = MimeTypes.Ogg, mimetype = MimeTypes.Ogg,
), ),
details = AudioDetails( details = AudioDetails(
duration = ofMinutes(1), waveform = listOf(1f, 2f) duration = 1.minutes,
waveform = persistentListOf(1f, 2f),
), ),
) )
), ),
@ -289,10 +291,10 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
body = "body.ogg", body = "body.ogg",
duration = ofMinutes(1), duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg, mimeType = MimeTypes.Ogg,
waveform = listOf(1f, 2f).toImmutableList() waveform = persistentListOf(1f, 2f)
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -315,7 +317,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body", body = "body",
duration = Duration.ZERO, duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream", mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
fileExtension = "" fileExtension = ""
) )
@ -336,7 +338,7 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = null, thumbnailSource = null,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
fileExtension = "", fileExtension = "",
mimeType = "application/octet-stream", mimeType = MimeTypes.OctetStream,
blurhash = null, blurhash = null,
width = null, width = null,
height = null, height = null,
@ -401,7 +403,7 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = null, thumbnailSource = null,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
fileExtension = "", fileExtension = "",
mimeType = "application/octet-stream" mimeType = MimeTypes.OctetStream
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }

View file

@ -38,6 +38,11 @@ import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.A_USER_ID_8 import io.element.android.libraries.matrix.test.A_USER_ID_8
import io.element.android.libraries.matrix.test.A_USER_ID_9 import io.element.android.libraries.matrix.test.A_USER_ID_9
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -55,7 +60,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes), eventId = null) factory.create(aPollContent(votes = votes), eventId = null)
) )
@ -87,7 +92,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
) )
@ -106,7 +111,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
) )
@ -136,7 +141,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest { fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null) factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
) )
@ -172,7 +177,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null) factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
) )
@ -192,7 +197,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null) factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
) )
@ -221,13 +226,13 @@ internal class TimelineItemContentPollFactoryTest {
private fun aPollContent( private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed, pollKind: PollKind = PollKind.Disclosed,
votes: Map<String, List<UserId>> = emptyMap(), votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null, endTime: ULong? = null,
): PollContent = PollContent( ): PollContent = PollContent(
question = A_POLL_QUESTION, question = A_POLL_QUESTION,
kind = pollKind, kind = pollKind,
maxSelections = 1UL, maxSelections = 1UL,
answers = listOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4), answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
votes = votes, votes = votes,
endTime = endTime, endTime = endTime,
isEdited = false, isEdited = false,
@ -277,17 +282,17 @@ internal class TimelineItemContentPollFactoryTest {
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries") private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger") private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
private val MY_USER_WINNING_VOTES = mapOf( private val MY_USER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4), A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
A_POLL_ANSWER_3 to emptyList(), A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to listOf(A_USER_ID_10), A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
) )
private val OTHER_WINNING_VOTES = mapOf( private val OTHER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6), A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
A_POLL_ANSWER_3 to emptyList(), A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
) )
} }
} }

View file

@ -86,11 +86,12 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem), id = computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem.copy("0"), aGroupableItem.copy("0"),
aGroupableItem.copy(id = "1"), aGroupableItem.copy(id = "1"),
).toImmutableList() ).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
), ),
) )
) )
@ -132,20 +133,22 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem), id = computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
).toImmutableList() ).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
), ),
aNonGroupableItem, aNonGroupableItem,
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem), id = computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
).toImmutableList() ).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
) )
) )
) )

View file

@ -0,0 +1,321 @@
/*
* 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
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
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.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest {
@Test
fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(eventContent = aMessageContent()).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
}
}
}
@Test
fun `an image message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = ImageMessageType(
body = "body",
source = aMediaSource(),
info = null,
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = VideoMessageType(
body = "body",
source = aMediaSource(),
info = VideoInfo(
duration = null,
height = null,
width = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = null
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = FileMessageType(
body = "body",
source = aMediaSource(),
info = FileInfo(
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.File,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = AudioMessageType(
body = "body",
source = aMediaSource(),
info = AudioInfo(
duration = null,
size = null,
mimetype = null
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
textContent = "body",
type = AttachmentThumbnailType.Audio,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a location message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = LocationMessageType(
body = "body",
geoUri = "geo:3.0,4.0;u=5.0",
description = null,
)
)
).metadata()
}
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Shared location",
type = AttachmentThumbnailType.Location,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a voice message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = VoiceMessageType(
body = "body",
source = aMediaSource(),
info = null,
details = null,
)
)
).metadata()
}
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Voice message",
type = AttachmentThumbnailType.Voice,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a poll content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aPollContent()
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Do you like polls?",
type = AttachmentThumbnailType.Poll,
blurHash = null,
)
)
)
}
}
}
@Test
fun `any other content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RedactedContent
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(null)
}
}
}
}
fun anInReplyToDetails(
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID,
senderDisplayName: String? = "senderDisplayName",
senderAvatarUrl: String? = "senderAvatarUrl",
eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent",
) = InReplyToDetails(
eventId = eventId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderAvatarUrl = senderAvatarUrl,
eventContent = eventContent,
textContent = textContent,
)
@Composable
private fun testEnv(content: @Composable () -> Any?): Any? {
var result: Any? = null
CompositionLocalProvider(
LocalConfiguration provides Configuration(),
LocalContext provides ApplicationProvider.getApplicationContext(),
) {
content().apply {
result = this
}
}
return result
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.FakeMediaLoader
@ -143,7 +144,7 @@ private fun createDefaultVoiceMessageMediaRepo(
url = mxcUri, url = mxcUri,
json = null json = null
), ),
mimeType = "audio/ogg", mimeType = MimeTypes.Ogg,
body = "someBody.ogg" body = "someBody.ogg"
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
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.libraries.core.mimetype.MimeTypes
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.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -287,7 +288,7 @@ private fun createDefaultVoiceMessagePlayer(
url = MXC_URI, url = MXC_URI,
json = null json = null
), ),
mimeType = "audio/ogg", mimeType = MimeTypes.Ogg,
body = "someBody.ogg" body = "someBody.ogg"
) )

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.core.mimetype.MimeTypes
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.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -37,7 +39,7 @@ class RedactedVoiceMessageManagerTest {
@Test @Test
fun `redacted event - no playing related media`() = runTest { fun `redacted event - no playing related media`() = runTest {
val mediaPlayer = FakeMediaPlayer().apply { val mediaPlayer = FakeMediaPlayer().apply {
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg") setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
play() play()
} }
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer) val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
@ -54,7 +56,7 @@ class RedactedVoiceMessageManagerTest {
@Test @Test
fun `redacted event - playing related media is paused`() = runTest { fun `redacted event - playing related media is paused`() = runTest {
val mediaPlayer = FakeMediaPlayer().apply { val mediaPlayer = FakeMediaPlayer().apply {
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg") setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
play() play()
} }
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer) val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
@ -87,8 +89,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
isOwn = false, isOwn = false,
isRemote = false, isRemote = false,
localSendState = null, localSendState = null,
reactions = listOf(), reactions = persistentListOf(),
receipts = listOf(), receipts = persistentListOf(),
sender = A_USER_ID, sender = A_USER_ID,
senderProfile = ProfileTimelineDetails.Unavailable, senderProfile = ProfileTimelineDetails.Unavailable,
timestamp = 9442, timestamp = 9442,

View file

@ -29,6 +29,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class VoiceMessagePresenterTest { class VoiceMessagePresenterTest {
@Test @Test
@ -49,7 +50,7 @@ class VoiceMessagePresenterTest {
fun `pressing play downloads and plays`() = runTest { fun `pressing play downloads and plays`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -87,7 +88,7 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
analyticsService = analyticsService, analyticsService = analyticsService,
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -123,7 +124,7 @@ class VoiceMessagePresenterTest {
fun `pressing pause while playing pauses`() = runTest { fun `pressing pause while playing pauses`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -172,7 +173,7 @@ class VoiceMessagePresenterTest {
fun `seeking before play`() = runTest { fun `seeking before play`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 10_000), content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -196,7 +197,7 @@ class VoiceMessagePresenterTest {
@Test @Test
fun `seeking after play`() = runTest { fun `seeking after play`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
content = aTimelineItemVoiceContent(durationMs = 10_000), content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

View file

@ -0,0 +1,24 @@
/*
* 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.poll.impl
internal object PollConstants {
const val MIN_ANSWERS = 2
const val MAX_ANSWERS = 20
const val MAX_ANSWER_LENGTH = 240
const val MAX_SELECTIONS = 1
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
sealed interface CreatePollEvents { sealed interface CreatePollEvents {
data object Save : CreatePollEvents data object Save : CreatePollEvents
data class Delete(val confirmed: Boolean) : CreatePollEvents
data class SetQuestion(val question: String) : CreatePollEvents data class SetQuestion(val question: String) : CreatePollEvents
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
data object AddAnswer : CreatePollEvents data object AddAnswer : CreatePollEvents

View file

@ -18,13 +18,11 @@ package io.element.android.features.poll.impl.create
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.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 androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -34,21 +32,19 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS
import io.element.android.features.poll.impl.data.PollRepository import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
private const val MIN_ANSWERS = 2
private const val MAX_ANSWERS = 20
private const val MAX_ANSWER_LENGTH = 240
private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor( class CreatePollPresenter @AssistedInject constructor(
private val repository: PollRepository, private val repository: PollRepository,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@ -64,17 +60,31 @@ class CreatePollPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): CreatePollState { override fun present(): CreatePollState {
var question: String by rememberSaveable { mutableStateOf("") } // The initial state of the form. In edit mode this will be populated with the poll being edited.
var answers: List<String> by rememberSaveable { mutableStateOf(listOf("", "")) } var initialPoll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) } mutableStateOf(PollFormState.Empty)
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) } }
// The current state of the form.
var poll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
mutableStateOf(initialPoll)
}
// Whether the form has been changed from the initial state
val isDirty: Boolean by remember { derivedStateOf { poll != initialPoll } }
var showBackConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (mode is CreatePollMode.EditPoll) { if (mode is CreatePollMode.EditPoll) {
repository.getPoll(mode.eventId).onSuccess { repository.getPoll(mode.eventId).onSuccess {
question = it.question val loadedPoll = PollFormState(
answers = it.answers.map(PollAnswer::text) question = it.question,
pollKind = it.kind answers = it.answers.map(PollAnswer::text).toPersistentList(),
isDisclosed = it.kind.isDisclosed,
)
initialPoll = loadedPoll
poll = loadedPoll
}.onFailure { }.onFailure {
analyticsService.trackGetPollFailed(it) analyticsService.trackGetPollFailed(it)
navigateUp() navigateUp()
@ -82,9 +92,9 @@ class CreatePollPresenter @AssistedInject constructor(
} }
} }
val canSave: Boolean by remember { derivedStateOf { canSave(question, answers) } } val canSave: Boolean by remember { derivedStateOf { poll.isValid } }
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } } val canAddAnswer: Boolean by remember { derivedStateOf { poll.canAddAnswer } }
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } } val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { poll.toUiAnswers() } }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -97,14 +107,14 @@ class CreatePollPresenter @AssistedInject constructor(
is CreatePollMode.EditPoll -> mode.eventId is CreatePollMode.EditPoll -> mode.eventId
is CreatePollMode.NewPoll -> null is CreatePollMode.NewPoll -> null
}, },
question = question, question = poll.question,
answers = answers, answers = poll.answers,
pollKind = pollKind, pollKind = poll.pollKind,
maxSelections = MAX_SELECTIONS, maxSelections = MAX_SELECTIONS,
).onSuccess { ).onSuccess {
analyticsService.capturePollSaved( analyticsService.capturePollSaved(
isUndisclosed = pollKind == PollKind.Undisclosed, isUndisclosed = poll.pollKind == PollKind.Undisclosed,
numberOfAnswers = answers.size, numberOfAnswers = poll.answers.size,
) )
}.onFailure { }.onFailure {
analyticsService.trackSavePollFailed(it, mode) analyticsService.trackSavePollFailed(it, mode)
@ -114,35 +124,52 @@ class CreatePollPresenter @AssistedInject constructor(
Timber.d("Cannot create poll") Timber.d("Cannot create poll")
} }
} }
is CreatePollEvents.AddAnswer -> { is CreatePollEvents.Delete -> {
answers = answers + "" if (mode !is CreatePollMode.EditPoll) {
} return
is CreatePollEvents.RemoveAnswer -> { }
answers = answers.filterIndexed { index, _ -> index != event.index }
} if (!event.confirmed) {
is CreatePollEvents.SetAnswer -> { showDeleteConfirmation = true
answers = answers.toMutableList().apply { return
this[event.index] = event.text.take(MAX_ANSWER_LENGTH) }
scope.launch {
showDeleteConfirmation = false
repository.deletePoll(mode.eventId)
navigateUp()
} }
} }
is CreatePollEvents.AddAnswer -> {
poll = poll.withNewAnswer()
}
is CreatePollEvents.RemoveAnswer -> {
poll= poll.withAnswerRemoved(event.index)
}
is CreatePollEvents.SetAnswer -> {
poll = poll.withAnswerChanged(event.index, event.text)
}
is CreatePollEvents.SetPollKind -> { is CreatePollEvents.SetPollKind -> {
pollKind = event.pollKind poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
} }
is CreatePollEvents.SetQuestion -> { is CreatePollEvents.SetQuestion -> {
question = event.question poll = poll.copy(question = event.question)
} }
is CreatePollEvents.NavBack -> { is CreatePollEvents.NavBack -> {
navigateUp() navigateUp()
} }
CreatePollEvents.ConfirmNavBack -> { CreatePollEvents.ConfirmNavBack -> {
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() } val shouldConfirm = isDirty
if (shouldConfirm) { if (shouldConfirm) {
showConfirmation = true showBackConfirmation = true
} else { } else {
navigateUp() navigateUp()
} }
} }
is CreatePollEvents.HideConfirmation -> showConfirmation = false is CreatePollEvents.HideConfirmation -> {
showBackConfirmation = false
showDeleteConfirmation = false
}
} }
} }
@ -153,10 +180,11 @@ class CreatePollPresenter @AssistedInject constructor(
}, },
canSave = canSave, canSave = canSave,
canAddAnswer = canAddAnswer, canAddAnswer = canAddAnswer,
question = question, question = poll.question,
answers = immutableAnswers, answers = immutableAnswers,
pollKind = pollKind, pollKind = poll.pollKind,
showConfirmation = showConfirmation, showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -207,35 +235,12 @@ private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreateP
trackError(exception) trackError(exception)
} }
private fun canSave( fun PollFormState.toUiAnswers(): ImmutableList<Answer> {
question: String, return answers.map { answer ->
answers: List<String>
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
fun List<String>.toAnswers(): ImmutableList<Answer> {
return map { answer ->
Answer( Answer(
text = answer, text = answer,
canDelete = this.size > MIN_ANSWERS, canDelete = canDeleteAnswer,
) )
}.toImmutableList() }.toImmutableList()
} }
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
save = {
when (it.value) {
PollKind.Disclosed -> false
PollKind.Undisclosed -> true
}
},
restore = {
mutableStateOf(
when (it) {
true -> PollKind.Undisclosed
else -> PollKind.Disclosed
}
)
}
)

View file

@ -26,13 +26,16 @@ data class CreatePollState(
val question: String, val question: String,
val answers: ImmutableList<Answer>, val answers: ImmutableList<Answer>,
val pollKind: PollKind, val pollKind: PollKind,
val showConfirmation: Boolean, val showBackConfirmation: Boolean,
val showDeleteConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit, val eventSink: (CreatePollEvents) -> Unit,
) { ) {
enum class Mode { enum class Mode {
New, New,
Edit, Edit,
} }
val canDelete: Boolean = mode == Mode.Edit
} }
data class Answer( data class Answer(

View file

@ -34,7 +34,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("", false) Answer("", false)
), ),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
showConfirmation = false, showBackConfirmation = false,
showDeleteConfirmation = false,
), ),
aCreatePollState( aCreatePollState(
mode = CreatePollState.Mode.New, mode = CreatePollState.Mode.New,
@ -45,7 +46,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
), ),
showConfirmation = false, showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
), ),
aCreatePollState( aCreatePollState(
@ -57,7 +59,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
), ),
showConfirmation = true, showBackConfirmation = true,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
), ),
aCreatePollState( aCreatePollState(
@ -71,7 +74,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true), Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true), Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
), ),
showConfirmation = false, showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
), ),
aCreatePollState( aCreatePollState(
@ -101,7 +105,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("19", true), Answer("19", true),
Answer("20", true), Answer("20", true),
), ),
showConfirmation = false, showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
), ),
aCreatePollState( aCreatePollState(
@ -124,7 +129,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
false false
), ),
), ),
showConfirmation = false, showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
), ),
aCreatePollState( aCreatePollState(
@ -137,7 +143,21 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
Answer("", false) Answer("", false)
), ),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
showConfirmation = false, showDeleteConfirmation = false,
showBackConfirmation = false,
),
aCreatePollState(
mode = CreatePollState.Mode.Edit,
canCreate = false,
canAddAnswer = true,
question = "",
answers = persistentListOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showDeleteConfirmation = true,
showBackConfirmation = false,
), ),
) )
} }
@ -148,7 +168,8 @@ private fun aCreatePollState(
canAddAnswer: Boolean, canAddAnswer: Boolean,
question: String, question: String,
answers: PersistentList<Answer>, answers: PersistentList<Answer>,
showConfirmation: Boolean, showBackConfirmation: Boolean,
showDeleteConfirmation: Boolean,
pollKind: PollKind pollKind: PollKind
): CreatePollState { ): CreatePollState {
return CreatePollState( return CreatePollState(
@ -157,7 +178,8 @@ private fun aCreatePollState(
canAddAnswer = canAddAnswer, canAddAnswer = canAddAnswer,
question = question, question = question,
answers = answers, answers = answers,
showConfirmation = showConfirmation, showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation,
pollKind = pollKind, pollKind = pollKind,
eventSink = {} eventSink = {}
) )

View file

@ -76,11 +76,21 @@ fun CreatePollView(
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) } val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
BackHandler(onBack = navBack) BackHandler(onBack = navBack)
if (state.showConfirmation) ConfirmationDialog( if (state.showBackConfirmation) {
content = stringResource(id = R.string.screen_create_poll_discard_confirmation), ConfirmationDialog(
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) }, content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android),
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
) onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
}
if (state.showDeleteConfirmation) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title),
content = stringResource(id = R.string.screen_edit_poll_delete_confirmation),
onSubmitClicked = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
}
val questionFocusRequester = remember { FocusRequester() } val questionFocusRequester = remember { FocusRequester() }
val answerFocusRequester = remember { FocusRequester() } val answerFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -191,6 +201,13 @@ fun CreatePollView(
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) }, onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
), ),
) )
if (state.canDelete) {
ListItem(
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
style = ListItemStyle.Destructive,
onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) },
)
}
} }
} }
} }

View file

@ -0,0 +1,130 @@
/*
* 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.poll.impl.create
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.mapSaver
import io.element.android.features.poll.impl.PollConstants
import io.element.android.features.poll.impl.PollConstants.MIN_ANSWERS
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
/**
* Represents the state of the poll creation / edit form.
*
* Save this state using [pollFormStateSaver].
*/
data class PollFormState(
val question: String,
val answers: ImmutableList<String>,
val isDisclosed: Boolean,
) {
companion object {
val Empty = PollFormState(
question = "",
answers = MutableList(MIN_ANSWERS) { "" }.toPersistentList(),
isDisclosed = true,
)
}
val pollKind
get() = when (isDisclosed) {
true -> PollKind.Disclosed
false -> PollKind.Undisclosed
}
/**
* Create a copy of the [PollFormState] with a new blank answer added.
*
* If the maximum number of answers has already been reached an answer is not added.
*/
fun withNewAnswer(): PollFormState {
if (!canAddAnswer) {
return this
}
return copy(answers = (answers + "").toPersistentList())
}
/**
* Create a copy of the [PollFormState] with the answer at [index] removed.
*
* If the answer doesn't exist or can't be removed, the state is unchanged.
*
* @param index the index of the answer to remove.
*
* @return a new [PollFormState] with the answer at [index] removed.
*/
fun withAnswerRemoved(index: Int): PollFormState {
if (!canDeleteAnswer) {
return this
}
return copy(answers = answers.filterIndexed { i, _ -> i != index }.toPersistentList())
}
/**
* Create a copy of the [PollFormState] with the answer at [index] changed.
*
* If the new answer is longer than [PollConstants.MAX_ANSWER_LENGTH], it will be truncated.
*
* @param index the index of the answer to change.
* @param rawAnswer the new answer as the user typed it.
*
* @return a new [PollFormState] with the answer at [index] changed.
*/
fun withAnswerChanged(index: Int, rawAnswer: String): PollFormState =
copy(answers = answers.toMutableList().apply {
this[index] = rawAnswer.take(PollConstants.MAX_ANSWER_LENGTH)
}.toPersistentList())
/**
* Whether a new answer can be added.
*/
val canAddAnswer get() = answers.size < PollConstants.MAX_ANSWERS
/**
* Whether any answer can be deleted.
*/
val canDeleteAnswer get() = answers.size > MIN_ANSWERS
/**
* Whether the form is currently valid.
*/
val isValid get() = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
}
/**
* A [Saver] for [PollFormState].
*/
internal val pollFormStateSaver = mapSaver(
save = {
mutableMapOf(
"question" to it.question,
"answers" to it.answers.toTypedArray(),
"isDisclosed" to it.isDisclosed,
)
},
restore = { saved ->
PollFormState(
question = saved["question"] as String,
answers = (saved["answers"] as Array<*>).map { it as String }.toPersistentList(),
isDisclosed = saved["isDisclosed"] as Boolean,
)
}
)

View file

@ -59,4 +59,11 @@ class PollRepository @Inject constructor(
pollKind = pollKind, pollKind = pollKind,
) )
} }
suspend fun deletePoll(
pollStartId: EventId,
): Result<Unit> =
room.redactEvent(
eventId = pollStartId,
)
} }

View file

@ -4,9 +4,11 @@
<string name="screen_create_poll_anonymous_desc">"Zobrazit výsledky až po skončení hlasování"</string> <string name="screen_create_poll_anonymous_desc">"Zobrazit výsledky až po skončení hlasování"</string>
<string name="screen_create_poll_anonymous_headline">"Anonymní hlasování"</string> <string name="screen_create_poll_anonymous_headline">"Anonymní hlasování"</string>
<string name="screen_create_poll_answer_hint">"Volba %1$d"</string> <string name="screen_create_poll_answer_hint">"Volba %1$d"</string>
<string name="screen_create_poll_discard_confirmation">"Opravdu chcete zrušit toto hlasování?"</string> <string name="screen_create_poll_cancel_confirmation_content_android">"Vaše změny nebyly uloženy. Opravdu se chcete vrátit?"</string>
<string name="screen_create_poll_discard_confirmation_title">"Zrušit hlasování"</string>
<string name="screen_create_poll_question_desc">"Otázka nebo téma"</string> <string name="screen_create_poll_question_desc">"Otázka nebo téma"</string>
<string name="screen_create_poll_question_hint">"Čeho se hlasování týká?"</string> <string name="screen_create_poll_question_hint">"Čeho se hlasování týká?"</string>
<string name="screen_create_poll_title">"Vytvořit hlasování"</string> <string name="screen_create_poll_title">"Vytvořit hlasování"</string>
<string name="screen_edit_poll_delete_confirmation">"Opravdu chcete odstranit toto hlasování?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Odstranit hlasování"</string>
<string name="screen_edit_poll_title">"Upravit hlasování"</string>
</resources> </resources>

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