Merge branch 'develop' into jonny/timeline-poll-edited
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,2 +1,3 @@
|
||||
**/snapshots/**/*.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
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -33,12 +33,12 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
|
||||
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
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
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
|
||||
6
.github/workflows/nightlyReports.yml
vendored
6
.github/workflows/nightlyReports.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: nschloe/action-cached-lfs-checkout@v1.2.2
|
||||
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
@@ -57,12 +57,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
||||
4
.github/workflows/quality.yml
vendored
4
.github/workflows/quality.yml
vendored
@@ -35,12 +35,12 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run code quality check suite
|
||||
|
||||
4
.github/workflows/recordScreenshots.yml
vendored
4
.github/workflows/recordScreenshots.yml
vendored
@@ -33,13 +33,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -20,12 +20,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
|
||||
4
.github/workflows/sonar.yml
vendored
4
.github/workflows/sonar.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: 🔊 Publish results to Sonar
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -39,12 +39,12 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
- name: ☕️ Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
uses: gradle/gradle-build-action@v2.10.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class MainNode(
|
||||
override val daggerComponent = (context as DaggerComponentOwner).daggerComponent
|
||||
|
||||
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
|
||||
return createNode<RootFlowNode>(context = buildContext)
|
||||
return createNode<RootFlowNode>(buildContext = buildContext)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -256,6 +256,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
is NavTarget.Room -> {
|
||||
val callback = object : RoomLoadedFlowNode.Callback {
|
||||
override fun onOpenRoom(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
coroutineScope.launch { attachRoom(roomId) }
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
), DaggerComponentOwner {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onOpenRoom(roomId: RoomId)
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
fun onOpenGlobalNotificationSettings()
|
||||
}
|
||||
@@ -134,6 +135,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
override fun onOpenGlobalNotificationSettings() {
|
||||
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
|
||||
}
|
||||
|
||||
override fun onOpenRoom(roomId: RoomId) {
|
||||
callbacks.forEach { it.onOpenRoom(roomId) }
|
||||
}
|
||||
}
|
||||
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(RoomDetailsEntryPoint.Params(initialTarget))
|
||||
|
||||
@@ -236,11 +236,11 @@ koverMerged {
|
||||
name = "Global minimum code coverage."
|
||||
target = kotlinx.kover.api.VerificationTarget.ALL
|
||||
bound {
|
||||
minValue = 60
|
||||
minValue = 65
|
||||
// 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
|
||||
// minValue to 25 and maxValue to 35.
|
||||
maxValue = 70
|
||||
maxValue = 75
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/+make-matrix-classes-immutable.misc
Normal file
1
changelog.d/+make-matrix-classes-immutable.misc
Normal file
@@ -0,0 +1 @@
|
||||
Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable.
|
||||
@@ -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
1
changelog.d/1451.feature
Normal file
@@ -0,0 +1 @@
|
||||
Display different notifications for mentions.
|
||||
1
changelog.d/1877.feature
Normal file
1
changelog.d/1877.feature
Normal file
@@ -0,0 +1 @@
|
||||
Scroll to end of timeline when sending a new message.
|
||||
1
changelog.d/1886.feature
Normal file
1
changelog.d/1886.feature
Normal file
@@ -0,0 +1 @@
|
||||
Confirm back navigation when editing a poll only if the poll was changed
|
||||
1
changelog.d/1895.feature
Normal file
1
changelog.d/1895.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add option to delete a poll while editing the poll
|
||||
1
changelog.d/1907.feature
Normal file
1
changelog.d/1907.feature
Normal 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
1
changelog.d/1912.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Use the right avatar for DMs in DM rooms
|
||||
1
changelog.d/1920.misc
Normal file
1
changelog.d/1920.misc
Normal file
@@ -0,0 +1 @@
|
||||
RoomList: introduce incremental loading to improve performances.
|
||||
@@ -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>>)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.features.createroom.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
@@ -77,11 +77,11 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.ConfigureRoom)
|
||||
}
|
||||
}
|
||||
createNode<AddPeopleNode>(context = buildContext, plugins = listOf(callback))
|
||||
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.ConfigureRoom -> {
|
||||
val callbacks = plugins<ConfigureRoomNode.Callback>()
|
||||
createNode<ConfigureRoomNode>(context = buildContext, plugins = callbacks)
|
||||
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||
}
|
||||
}
|
||||
createNode<CreateRoomRootNode>(context = buildContext, plugins = listOf(callback))
|
||||
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.NewRoom -> {
|
||||
val callback = object : ConfigureRoomNode.Callback {
|
||||
@@ -80,7 +80,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||
}
|
||||
}
|
||||
createNode<ConfigureRoomFlowNode>(context = buildContext, plugins = listOf(callback))
|
||||
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,16 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.UserListDataStore
|
||||
import io.element.android.features.createroom.impl.userlist.UserListPresenter
|
||||
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
|
||||
import io.element.android.libraries.architecture.Async
|
||||
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.matrix.api.MatrixClient
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -43,8 +38,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||
presenterFactory: UserListPresenter.Factory,
|
||||
userRepository: UserRepository,
|
||||
userListDataStore: UserListDataStore,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val startDMAction: StartDMAction,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<CreateRoomRootState> {
|
||||
|
||||
@@ -61,37 +55,22 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||
val userListState = presenter.present()
|
||||
|
||||
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) {
|
||||
when (event) {
|
||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction)
|
||||
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
|
||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
|
||||
startDMAction.execute(event.matrixUser.userId, startDmActionState)
|
||||
}
|
||||
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return CreateRoomRootState(
|
||||
applicationName = buildMeta.applicationName,
|
||||
userListState = userListState,
|
||||
startDmAction = startDmAction.value,
|
||||
startDmAction = startDmActionState.value,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,25 +20,21 @@ import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
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.FakeUserListPresenterFactory
|
||||
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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
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.FakeMatrixClient
|
||||
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.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -47,142 +43,57 @@ class CreateRoomRootPresenterTests {
|
||||
@get:Rule
|
||||
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
|
||||
fun `present - initial state`() = runTest {
|
||||
fun `present - start DM action complete scenario`() = runTest {
|
||||
val startDMAction = FakeStartDMAction()
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.userListState.isSearchActive).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 createDmResult = Result.success(RoomId("!createDmResult:domain"))
|
||||
|
||||
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)
|
||||
val startDMSuccessResult = Async.Success(A_ROOM_ID)
|
||||
val startDMFailureResult = Async.Failure<RoomId>(A_THROWABLE)
|
||||
|
||||
// Failure
|
||||
startDMAction.givenExecuteResult(startDMFailureResult)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||
val stateAfterStartDM = awaitItem()
|
||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
|
||||
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
|
||||
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
}
|
||||
|
||||
// Cancel
|
||||
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
val stateAfterCancel = awaitItem()
|
||||
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||
|
||||
// Failure
|
||||
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
// Success
|
||||
startDMAction.givenExecuteResult(startDMSuccessResult)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(Async.Uninitialized)
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
}
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||
val stateAfterSecondAttempt = awaitItem()
|
||||
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
|
||||
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
31
features/createroom/test/build.gradle.kts
Normal file
31
features/createroom/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -140,8 +140,7 @@ private fun BackupUploadState.isBackingUp(): Boolean {
|
||||
return when (this) {
|
||||
BackupUploadState.Unknown,
|
||||
BackupUploadState.Waiting,
|
||||
is BackupUploadState.Uploading,
|
||||
is BackupUploadState.CheckingIfUploadNeeded -> true
|
||||
is BackupUploadState.Uploading -> true
|
||||
is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection
|
||||
BackupUploadState.Done,
|
||||
BackupUploadState.Error -> false
|
||||
|
||||
@@ -23,6 +23,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.messages.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
@@ -48,6 +53,7 @@ dependencies {
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.eventformatter.api)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
@@ -87,7 +93,10 @@ dependencies {
|
||||
testImplementation(projects.libraries.textcomposer.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(projects.libraries.mediaplayer.test)
|
||||
testImplementation(projects.libraries.mediaviewer.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.test.junitext)
|
||||
testImplementation(libs.test.robolectric)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -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.preview.AttachmentsPreviewNode
|
||||
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.timeline.debug.EventDebugInfoNode
|
||||
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.media.MediaSource
|
||||
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.parcelize.Parcelize
|
||||
|
||||
@@ -180,6 +180,8 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
canDownload = true,
|
||||
canShare = true,
|
||||
)
|
||||
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
|
||||
@@ -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.MatrixRoomMembersState
|
||||
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.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.room.canRedactAsState
|
||||
@@ -108,6 +109,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||
@@ -144,6 +146,16 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
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) }
|
||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
|
||||
@@ -162,8 +174,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
// TODO add min power level to use this feature in the future?
|
||||
val enableInRoomCalls = true
|
||||
LaunchedEffect(featureFlagsService) {
|
||||
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(
|
||||
roomId = room.roomId,
|
||||
roomName = roomName,
|
||||
@@ -213,9 +229,8 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
inviteProgress = inviteProgress.value,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
enableInRoomCalls = enableInRoomCalls,
|
||||
appName = buildMeta.applicationName,
|
||||
isCallOngoing = roomInfo?.hasRoomCall ?: false,
|
||||
callState = callState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
@@ -224,7 +239,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
return AvatarData(
|
||||
id = id,
|
||||
name = name,
|
||||
url = avatarUrl,
|
||||
url = avatarUrl ?: room.avatarUrl,
|
||||
size = AvatarSize.TimelineRoom
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,8 +51,13 @@ data class MessagesState(
|
||||
val showReinvitePrompt: Boolean,
|
||||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val isCallOngoing: Boolean,
|
||||
val callState: RoomCallState,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
||||
enum class RoomCallState {
|
||||
ENABLED,
|
||||
ONGOING,
|
||||
DISABLED
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
isCallOngoing = true,
|
||||
callState = RoomCallState.ONGOING,
|
||||
),
|
||||
aMessagesState().copy(
|
||||
enableVoiceMessages = true,
|
||||
@@ -75,6 +75,9 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
showSendFailureDialog = true
|
||||
),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
callState = RoomCallState.DISABLED,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,8 +120,7 @@ fun aMessagesState() = MessagesState(
|
||||
showReinvitePrompt = false,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
isCallOngoing = false,
|
||||
callState = RoomCallState.ENABLED,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -191,10 +191,9 @@ fun MessagesView(
|
||||
MessagesViewTopBar(
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||
inRoomCallsEnabled = state.enableInRoomCalls,
|
||||
callState = state.callState,
|
||||
onBackPressed = onBackPressed,
|
||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||
isCallOngoing = state.isCallOngoing,
|
||||
onJoinCallClicked = onJoinCallClicked,
|
||||
)
|
||||
}
|
||||
@@ -449,8 +448,7 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||
private fun MessagesViewTopBar(
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
inRoomCallsEnabled: Boolean,
|
||||
isCallOngoing: Boolean,
|
||||
callState: RoomCallState,
|
||||
onRoomDetailsClicked: () -> Unit,
|
||||
onJoinCallClicked: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
@@ -477,13 +475,11 @@ private fun MessagesViewTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (inRoomCallsEnabled) {
|
||||
if (isCallOngoing) {
|
||||
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
|
||||
} else {
|
||||
IconButton(onClick = onJoinCallClicked) {
|
||||
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
|
||||
}
|
||||
if (callState == RoomCallState.ONGOING) {
|
||||
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
|
||||
} else {
|
||||
IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) {
|
||||
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
@@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.attachments
|
||||
|
||||
import android.os.Parcelable
|
||||
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
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
@@ -114,6 +115,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
sendActionState.value = SendActionState.Done
|
||||
},
|
||||
onFailure = { error ->
|
||||
Timber.e(error, "Failed to send attachment")
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
} else {
|
||||
|
||||
@@ -19,10 +19,10 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.features.messages.impl.media.local.aFileInfo
|
||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.aFileInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anImageInfo
|
||||
|
||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.media.local.LocalMediaView
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
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.theme.components.Scaffold
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -34,7 +34,6 @@ import androidx.media3.common.util.UnstableApi
|
||||
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.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.MentionSuggestionsProcessor
|
||||
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.mediapickers.api.PickerProvider
|
||||
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.PermissionsPresenter
|
||||
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.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -432,6 +433,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
attachmentState.value = AttachmentsState.None
|
||||
}
|
||||
.onFailure { cause ->
|
||||
Timber.e(cause, "Failed to send attachment")
|
||||
attachmentState.value = AttachmentsState.None
|
||||
if (cause is CancellationException) {
|
||||
throw cause
|
||||
|
||||
@@ -34,6 +34,7 @@ import im.vector.app.features.analytics.plan.PollEnd
|
||||
import im.vector.app.features.analytics.plan.PollVote
|
||||
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.model.NewEventState
|
||||
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.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -98,7 +99,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val hasNewItems = remember { mutableStateOf(false) }
|
||||
val newItemState = remember { mutableStateOf(NewEventState.None) }
|
||||
|
||||
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
|
||||
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||
@@ -121,7 +122,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
if (event.firstIndex == 0) {
|
||||
hasNewItems.value = false
|
||||
newItemState.value = NewEventState.None
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
@@ -150,7 +151,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -182,7 +183,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
showReadReceipts = readReceiptsEnabled,
|
||||
hasNewItems = hasNewItems.value,
|
||||
newEventState = newItemState.value,
|
||||
sessionState = sessionState,
|
||||
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.
|
||||
* 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>,
|
||||
prevMostRecentItemId: MutableState<String?>,
|
||||
hasNewItemsState: MutableState<Boolean>
|
||||
newEventState: MutableState<NewEventState>
|
||||
) = 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 prevMostRecentItemIdValue = prevMostRecentItemId.value
|
||||
val newMostRecentItemId = newMostRecentItem?.identifier()
|
||||
val hasNewItems = prevMostRecentItemIdValue != null &&
|
||||
val hasNewEvent = prevMostRecentItemIdValue != null &&
|
||||
newMostRecentItem is TimelineItem.Event &&
|
||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||
newMostRecentItemId != prevMostRecentItemIdValue
|
||||
if (hasNewItems) {
|
||||
hasNewItemsState.value = true
|
||||
if (hasNewEvent) {
|
||||
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||
val fromMe = newMostRecentEvent?.localSendState != null
|
||||
newEventState.value = if (fromMe) {
|
||||
NewEventState.FromMe
|
||||
} else {
|
||||
NewEventState.FromOther
|
||||
}
|
||||
}
|
||||
prevMostRecentItemId.value = newMostRecentItemId
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
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.session.SessionState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -30,7 +31,7 @@ data class TimelineState(
|
||||
val highlightedEventId: EventId?,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val hasNewItems: Boolean,
|
||||
val newEventState: NewEventState,
|
||||
val sessionState: SessionState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
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.NewEventState
|
||||
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.TimelineItemGroupPosition
|
||||
@@ -53,7 +55,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
||||
),
|
||||
highlightedEventId = null,
|
||||
userHasPermissionToSendMessage = true,
|
||||
hasNewItems = false,
|
||||
newEventState = NewEventState.None,
|
||||
sessionState = aSessionState(
|
||||
isSessionVerified = true,
|
||||
isKeyBackupEnabled = true,
|
||||
@@ -186,17 +188,27 @@ internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
|
||||
)
|
||||
}
|
||||
|
||||
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||
val event = aTimelineItemEvent(
|
||||
internal fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||
val event1 = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
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(
|
||||
id = id.toString(),
|
||||
events = listOf(
|
||||
event,
|
||||
event,
|
||||
).toImmutableList()
|
||||
events = events.toImmutableList(),
|
||||
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,13 +20,11 @@ package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -42,36 +40,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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.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.TimelineItemRow
|
||||
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.di.LocalTimelineItemPresenterFactories
|
||||
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.event.TimelineItemEventContent
|
||||
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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -167,141 +156,44 @@ fun TimelineView(
|
||||
TimelineScrollHelper(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
lazyListState = lazyListState,
|
||||
hasNewItems = state.hasNewItems,
|
||||
newEventState = state.newEventState,
|
||||
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
|
||||
private fun BoxScope.TimelineScrollHelper(
|
||||
isTimelineEmpty: Boolean,
|
||||
lazyListState: LazyListState,
|
||||
hasNewItems: Boolean,
|
||||
newEventState: NewEventState,
|
||||
onScrollFinishedAt: (Int) -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
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) {
|
||||
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
|
||||
if (shouldAutoScroll) {
|
||||
coroutineScope.launch {
|
||||
fun scrollToBottom() {
|
||||
coroutineScope.launch {
|
||||
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||
lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(canAutoScroll, newEventState) {
|
||||
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
||||
if (isScrollFinished && !isTimelineEmpty) {
|
||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||
@@ -315,15 +207,7 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||
lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick = ::scrollToBottom,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.constraintlayout.compose.ConstrainScope
|
||||
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.aTimelineItemEvent
|
||||
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.TimelineItemReadReceiptView
|
||||
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.TimelineItemGroupPosition
|
||||
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.aTimelineItemImageContent
|
||||
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.components.EqualWidthColumn
|
||||
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.matrix.api.core.EventId
|
||||
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.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 kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
@@ -171,8 +163,6 @@ fun TimelineItemEventRow(
|
||||
state = state.draggableState,
|
||||
),
|
||||
event = event,
|
||||
showReadReceipts = showReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
@@ -183,7 +173,6 @@ fun TimelineItemEventRow(
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
@@ -191,8 +180,6 @@ fun TimelineItemEventRow(
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
showReadReceipts = showReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
@@ -203,10 +190,20 @@ fun TimelineItemEventRow(
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||
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
|
||||
private fun TimelineItemEventRowContent(
|
||||
event: TimelineItem.Event,
|
||||
showReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
isHighlighted: Boolean,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
@@ -246,7 +241,6 @@ private fun TimelineItemEventRowContent(
|
||||
inReplyToClicked: () -> Unit,
|
||||
onUserDataClicked: () -> Unit,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReadReceiptsClicked: () -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
@@ -267,7 +261,6 @@ private fun TimelineItemEventRowContent(
|
||||
sender,
|
||||
message,
|
||||
reactions,
|
||||
readReceipts,
|
||||
) = createRefs()
|
||||
|
||||
// Sender
|
||||
@@ -334,25 +327,6 @@ private fun TimelineItemEventRowContent(
|
||||
.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 senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
|
||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
|
||||
val text = textForInReplyTo(inReplyTo)
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
ReplyToContent(
|
||||
senderName = senderName,
|
||||
text = text,
|
||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
@@ -585,11 +556,10 @@ private fun MessageEventBubbleContent(
|
||||
@Composable
|
||||
private fun ReplyToContent(
|
||||
senderName: String,
|
||||
text: String?,
|
||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
metadata: InReplyToMetadata?,
|
||||
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)
|
||||
} else {
|
||||
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
@@ -599,9 +569,9 @@ private fun ReplyToContent(
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
if (attachmentThumbnailInfo != null) {
|
||||
if (metadata is InReplyToMetadata.Thumbnail) {
|
||||
AttachmentThumbnail(
|
||||
info = attachmentThumbnailInfo,
|
||||
info = metadata.attachmentThumbnailInfo,
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
@@ -619,7 +589,7 @@ private fun ReplyToContent(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = text.orEmpty(),
|
||||
text = metadata?.text.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
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
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
|
||||
@@ -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.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
@@ -127,9 +129,9 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
||||
question = "Poll which are being replied.",
|
||||
kind = PollKind.Disclosed,
|
||||
maxSelections = 1u,
|
||||
answers = emptyList(),
|
||||
votes = emptyMap(),
|
||||
endTime = null,
|
||||
answers = persistentListOf(),
|
||||
votes = persistentMapOf(),
|
||||
endTime = null
|
||||
isEdited = false,
|
||||
),
|
||||
).map {
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.components.event.TimelineItemEventContentView
|
||||
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.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.util.defaultTimelineContentPadding
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
fun TimelineItemStateEventRow(
|
||||
event: TimelineItem.Event,
|
||||
showReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MessageStateEventContainer(
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 2.dp)
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
isMine = event.isMine,
|
||||
isEditable = event.isEditable,
|
||||
MessageStateEventContainer(
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
extraPadding = noExtraPadding,
|
||||
eventSink = eventSink,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.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(
|
||||
isMine = false,
|
||||
content = aTimelineItemStateEventContent(),
|
||||
groupPosition = TimelineItemGroupPosition.None
|
||||
groupPosition = TimelineItemGroupPosition.None,
|
||||
readReceiptState = TimelineItemReadReceipts(
|
||||
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
|
||||
)
|
||||
),
|
||||
showReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
isHighlighted = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onReadReceiptsClick = {},
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
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.layout.Arrangement
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -76,9 +79,17 @@ fun GroupHeaderView(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
val rotation: Float by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 90f else 0f,
|
||||
animationSpec = tween(
|
||||
delayMillis = 0,
|
||||
durationMillis = 300,
|
||||
),
|
||||
label = "chevron"
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.rotate(if (isExpanded) 180f else 0f),
|
||||
imageVector = CompoundIcons.ChevronDown,
|
||||
modifier = Modifier.rotate(rotation),
|
||||
imageVector = CompoundIcons.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.reactionsummary
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -38,10 +37,6 @@ class ReactionSummaryPresenter @Inject constructor(
|
||||
) : Presenter<ReactionSummaryState> {
|
||||
@Composable
|
||||
override fun present(): ReactionSummaryState {
|
||||
LaunchedEffect(Unit) {
|
||||
room.updateMembers()
|
||||
}
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
|
||||
val target: MutableState<ReactionSummaryState.Summary?> = remember {
|
||||
|
||||
@@ -33,23 +33,23 @@ class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewSta
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } },
|
||||
receipts = List(1) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } },
|
||||
receipts = List(2) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } },
|
||||
receipts = List(3) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } },
|
||||
receipts = List(4) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } },
|
||||
receipts = List(5) { aReadReceiptData(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ package io.element.android.features.messages.impl.timeline.components.receipt.bo
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
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.user.MatrixUser
|
||||
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 kotlinx.coroutines.launch
|
||||
|
||||
@@ -83,34 +84,39 @@ internal fun ReadReceiptBottomSheet(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ReadReceiptBottomSheetContent(
|
||||
private fun ReadReceiptBottomSheetContent(
|
||||
state: ReadReceiptBottomSheetState,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = CommonStrings.common_seen_by))
|
||||
LazyColumn {
|
||||
item {
|
||||
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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.TimelineItemVideoContent
|
||||
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.core.mimetype.MimeTypes
|
||||
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.VoiceMessageType
|
||||
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.toImmutableList
|
||||
import java.time.Duration
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
class TimelineItemContentMessageFactory @Inject constructor(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) {
|
||||
|
||||
@@ -104,7 +104,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
duration = messageType.info?.duration?.toMillis() ?: 0L,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
|
||||
@@ -73,7 +73,8 @@ private fun MutableList<TimelineItem>.addGroup(
|
||||
add(
|
||||
TimelineItem.GroupedEvents(
|
||||
id = groupId,
|
||||
events = groupOfItems.toImmutableList()
|
||||
events = groupOfItems.toImmutableList(),
|
||||
aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,13 +16,10 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<TimelineItemGroupPosition> {
|
||||
override val values = sequenceOf(
|
||||
TimelineItemGroupPosition.First,
|
||||
TimelineItemGroupPosition.Middle,
|
||||
TimelineItemGroupPosition.Last,
|
||||
TimelineItemGroupPosition.None,
|
||||
)
|
||||
/**
|
||||
* Model if there is a new event in the timeline and if it is from me or from other.
|
||||
* This can be used to scroll to the bottom of the list when a new event is added.
|
||||
*/
|
||||
enum class NewEventState {
|
||||
None, FromMe, FromOther
|
||||
}
|
||||
@@ -88,6 +88,6 @@ sealed interface TimelineItem {
|
||||
data class GroupedEvents(
|
||||
val id: String,
|
||||
val events: ImmutableList<Event>,
|
||||
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
|
||||
) : TimelineItem
|
||||
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
|
||||
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 java.time.Duration
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemAudioContent(
|
||||
val body: String,
|
||||
@@ -29,6 +28,10 @@ data class TimelineItemAudioContent(
|
||||
val fileExtension: String,
|
||||
) : TimelineItemEventContent {
|
||||
|
||||
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
|
||||
val fileExtensionAndSize =
|
||||
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
|
||||
fileExtension,
|
||||
formattedFileSize
|
||||
)
|
||||
override val type: String = "TimelineItemAudioContent"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import java.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
|
||||
override val values: Sequence<TimelineItemAudioContent>
|
||||
@@ -35,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu
|
||||
mimeType = MimeTypes.Pdf,
|
||||
formattedFileSize = "100kB",
|
||||
fileExtension = "mp3",
|
||||
duration = Duration.ofMillis(100),
|
||||
duration = 100.milliseconds,
|
||||
mediaSource = MediaSource(""),
|
||||
)
|
||||
|
||||
@@ -79,6 +79,8 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
|
||||
|
||||
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
|
||||
|
||||
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent(
|
||||
body = "A state event",
|
||||
fun aTimelineItemStateEventContent(
|
||||
body: String = "A state event",
|
||||
) = TimelineItemStateEventContent(
|
||||
body = body,
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
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.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||
|
||||
data class TimelineItemFileContent(
|
||||
val body: String,
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemVideoContent(
|
||||
val body: String,
|
||||
val duration: Long,
|
||||
val duration: Duration,
|
||||
val videoSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val aspectRatio: Float?,
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
||||
override val values: Sequence<TimelineItemVideoContent>
|
||||
@@ -35,7 +36,7 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||
thumbnailSource = null,
|
||||
blurHash = A_BLUR_HASH,
|
||||
aspectRatio = 0.5f,
|
||||
duration = 100,
|
||||
duration = 100.milliseconds,
|
||||
videoSource = MediaSource(""),
|
||||
height = 300,
|
||||
width = 150,
|
||||
|
||||
@@ -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.media.MediaSource
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import java.time.Duration
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemVoiceContent(
|
||||
val eventId: EventId?,
|
||||
|
||||
@@ -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.media.MediaSource
|
||||
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> {
|
||||
override val values: Sequence<TimelineItemVoiceContent>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemVoiceContent(
|
||||
durationMs = 1,
|
||||
duration = 1.milliseconds,
|
||||
waveform = listOf(),
|
||||
),
|
||||
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),
|
||||
),
|
||||
aTimelineItemVoiceContent(
|
||||
durationMs = 1_800_000, // 30 minutes
|
||||
duration = 30.minutes,
|
||||
waveform = List(1024) { it / 1024f },
|
||||
),
|
||||
)
|
||||
@@ -44,14 +46,14 @@ open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineI
|
||||
fun aTimelineItemVoiceContent(
|
||||
eventId: String? = "\$anEventId",
|
||||
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",
|
||||
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),
|
||||
) = TimelineItemVoiceContent(
|
||||
eventId = eventId?.let { EventId(it) },
|
||||
body = body,
|
||||
duration = Duration.ofMillis(durationMs),
|
||||
duration = duration,
|
||||
mediaSource = MediaSource(contentUri),
|
||||
mimeType = mimeType,
|
||||
waveform = waveform.toPersistentList(),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -41,7 +42,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
const val MIME_TYPE = "audio/ogg"
|
||||
const val MIME_TYPE = MimeTypes.Ogg
|
||||
}
|
||||
|
||||
private var mediaPath: String? = null
|
||||
@@ -201,6 +202,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||
progress = 0f,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
@@ -212,7 +214,6 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||
val isStopped get() = this.playState == PlayState.Stopped
|
||||
}
|
||||
|
||||
|
||||
enum class PlayState {
|
||||
/**
|
||||
* The player is stopped, i.e. it has just been initialised.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
@@ -196,7 +197,7 @@ class DefaultVoiceMessagePlayer(
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaFile.path,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
val duration by remember {
|
||||
derivedStateOf { playerState.duration ?: content.duration.toMillis() }
|
||||
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
|
||||
}
|
||||
val progress by remember {
|
||||
derivedStateOf {
|
||||
|
||||
@@ -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_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_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_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
|
||||
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<string name="screen_room_notification_settings_error_loading_settings">"Une erreur s’est 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_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
|
||||
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Votre serveur d’accueil 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_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
|
||||
<string name="screen_room_reactions_show_less">"Afficher moins"</string>
|
||||
|
||||
@@ -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.fixtures.aMessageEvent
|
||||
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.MessageComposerPresenter
|
||||
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.mediaupload.api.MediaSender
|
||||
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.test.FakePermissionsPresenter
|
||||
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.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class MessagesPresenterTest {
|
||||
|
||||
@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
|
||||
fun `present - handle toggling a reaction`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
@@ -264,7 +281,7 @@ class MessagesPresenterTest {
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
body = "video.mp4",
|
||||
duration = 10L,
|
||||
duration = 10.milliseconds,
|
||||
videoSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||
mimeType = MimeTypes.Mp4,
|
||||
@@ -462,7 +479,7 @@ class MessagesPresenterTest {
|
||||
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||
room.givenRoomMembersState(
|
||||
MatrixRoomMembersState.Ready(
|
||||
listOf(
|
||||
persistentListOf(
|
||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||
)
|
||||
@@ -489,7 +506,7 @@ class MessagesPresenterTest {
|
||||
room.givenRoomMembersState(
|
||||
MatrixRoomMembersState.Error(
|
||||
failure = Throwable(),
|
||||
prevRoomMembers = listOf(
|
||||
prevRoomMembers = persistentListOf(
|
||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||
)
|
||||
@@ -535,7 +552,7 @@ class MessagesPresenterTest {
|
||||
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||
room.givenRoomMembersState(
|
||||
MatrixRoomMembersState.Ready(
|
||||
listOf(
|
||||
persistentListOf(
|
||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||
)
|
||||
@@ -655,6 +672,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
currentSessionIdHolder: CurrentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
@@ -723,6 +741,7 @@ class MessagesPresenterTest {
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
currentSessionIdHolder = currentSessionIdHolder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.AttachmentsPreviewPresenter
|
||||
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.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
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.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
@@ -16,22 +16,10 @@
|
||||
|
||||
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.media.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
|
||||
)
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
|
||||
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||
localMedia = localMedia,
|
||||
compressIfPossible = compressIfPossible,
|
||||
)
|
||||
|
||||
@@ -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.TimelineItemVirtualFactory
|
||||
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.dateformatter.test.FakeDaySeparatorFormatter
|
||||
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.matrix.api.timeline.item.event.EventTimelineItem
|
||||
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 kotlinx.coroutines.test.TestScope
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.messagecomposer.AttachmentsState
|
||||
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.MediaUploadInfo
|
||||
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.test.FakePermissionsPresenter
|
||||
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.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.internal.immutableListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import uniffi.wysiwyg_composer.MentionsState
|
||||
@@ -734,7 +734,7 @@ class MessageComposerPresenterTest {
|
||||
isOneToOne = false,
|
||||
).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||
immutableListOf(currentUser, invitedUser, bob, david),
|
||||
persistentListOf(currentUser, invitedUser, bob, david),
|
||||
))
|
||||
givenCanTriggerRoomNotification(Result.success(true))
|
||||
}
|
||||
@@ -798,7 +798,7 @@ class MessageComposerPresenterTest {
|
||||
isOneToOne = true,
|
||||
).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||
immutableListOf(currentUser, invitedUser, bob, david),
|
||||
persistentListOf(currentUser, invitedUser, bob, david),
|
||||
))
|
||||
givenCanTriggerRoomNotification(Result.success(true))
|
||||
}
|
||||
|
||||
@@ -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.aTimelineItemsFactory
|
||||
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.session.SessionState
|
||||
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.MatrixTimelineItem
|
||||
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.virtual.VirtualTimelineItem
|
||||
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.services.analytics.test.FakeAnalyticsService
|
||||
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.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -187,28 +192,49 @@ class TimelinePresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - covers hasNewItems scenarios`() = runTest {
|
||||
fun `present - covers newEventState scenarios`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasNewItems).isFalse()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
timeline.updateTimelineItems {
|
||||
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// Mimics sending a message, and assert newEventState is FromMe
|
||||
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)
|
||||
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
|
||||
assertThat(awaitItem().hasNewItems).isTrue()
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 2 }
|
||||
awaitLastSequentialItem().also { state ->
|
||||
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))
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -221,7 +247,7 @@ class TimelinePresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasNewItems).isFalse()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
val now = Date().time
|
||||
val minuteInMillis = 60 * 1000
|
||||
@@ -229,18 +255,18 @@ class TimelinePresenterTest {
|
||||
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
|
||||
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
|
||||
}
|
||||
val oneReaction = listOf(
|
||||
val oneReaction = persistentListOf(
|
||||
EventReaction(
|
||||
key = "❤️",
|
||||
senders = listOf(alice, charlie)
|
||||
senders = persistentListOf(alice, charlie)
|
||||
),
|
||||
EventReaction(
|
||||
key = "👍",
|
||||
senders = listOf(alice, bob)
|
||||
senders = persistentListOf(alice, bob)
|
||||
),
|
||||
EventReaction(
|
||||
key = "🐶",
|
||||
senders = listOf(charlie)
|
||||
senders = persistentListOf(charlie)
|
||||
),
|
||||
)
|
||||
timeline.updateTimelineItems {
|
||||
|
||||
@@ -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.aRoomMember
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -42,7 +43,7 @@ class ReactionSummaryPresenterTests {
|
||||
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 room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
|
||||
}
|
||||
private val presenter = ReactionSummaryPresenter(room)
|
||||
|
||||
|
||||
@@ -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.TimelineItemVideoContent
|
||||
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.core.mimetype.MimeTypes
|
||||
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.test.AN_EVENT_ID
|
||||
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.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.time.Duration
|
||||
import java.time.Duration.ofMinutes
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class TimelineItemContentMessageFactoryTest {
|
||||
|
||||
@@ -140,14 +141,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body",
|
||||
duration = 0,
|
||||
duration = Duration.ZERO,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = null,
|
||||
aspectRatio = null,
|
||||
blurHash = null,
|
||||
height = null,
|
||||
width = null,
|
||||
mimeType = "application/octet-stream",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
formattedFileSize = "0 Bytes",
|
||||
fileExtension = "",
|
||||
)
|
||||
@@ -163,7 +164,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body.mp4",
|
||||
source = MediaSource("url"),
|
||||
info = VideoInfo(
|
||||
duration = ofMinutes(1),
|
||||
duration = 1.minutes,
|
||||
height = 100,
|
||||
width = 300,
|
||||
mimetype = MimeTypes.Mp4,
|
||||
@@ -184,7 +185,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body.mp4",
|
||||
duration = 60_000,
|
||||
duration = 1.minutes,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
aspectRatio = 3f,
|
||||
@@ -210,7 +211,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body",
|
||||
duration = Duration.ZERO,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
mimeType = "application/octet-stream",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
formattedFileSize = "0 Bytes",
|
||||
fileExtension = "",
|
||||
)
|
||||
@@ -226,7 +227,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body.mp3",
|
||||
source = MediaSource("url"),
|
||||
info = AudioInfo(
|
||||
duration = ofMinutes(1),
|
||||
duration = 1.minutes,
|
||||
size = 123L,
|
||||
mimetype = MimeTypes.Mp3,
|
||||
)
|
||||
@@ -237,7 +238,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
body = "body.mp3",
|
||||
duration = ofMinutes(1),
|
||||
duration = 1.minutes,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
mimeType = MimeTypes.Mp3,
|
||||
formattedFileSize = "123 Bytes",
|
||||
@@ -259,7 +260,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body",
|
||||
duration = Duration.ZERO,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
mimeType = "application/octet-stream",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
waveform = emptyList<Float>().toImmutableList()
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
@@ -274,12 +275,13 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body.ogg",
|
||||
source = MediaSource("url"),
|
||||
info = AudioInfo(
|
||||
duration = ofMinutes(1),
|
||||
duration = 1.minutes,
|
||||
size = 123L,
|
||||
mimetype = MimeTypes.Ogg,
|
||||
),
|
||||
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(
|
||||
eventId = AN_EVENT_ID,
|
||||
body = "body.ogg",
|
||||
duration = ofMinutes(1),
|
||||
duration = 1.minutes,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
mimeType = MimeTypes.Ogg,
|
||||
waveform = listOf(1f, 2f).toImmutableList()
|
||||
waveform = persistentListOf(1f, 2f)
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
@@ -315,7 +317,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
body = "body",
|
||||
duration = Duration.ZERO,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
mimeType = "application/octet-stream",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
formattedFileSize = "0 Bytes",
|
||||
fileExtension = ""
|
||||
)
|
||||
@@ -336,7 +338,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
thumbnailSource = null,
|
||||
formattedFileSize = "0 Bytes",
|
||||
fileExtension = "",
|
||||
mimeType = "application/octet-stream",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
blurhash = null,
|
||||
width = null,
|
||||
height = null,
|
||||
@@ -401,7 +403,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
thumbnailSource = null,
|
||||
formattedFileSize = "0 Bytes",
|
||||
fileExtension = "",
|
||||
mimeType = "application/octet-stream"
|
||||
mimeType = MimeTypes.OctetStream
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@@ -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_9
|
||||
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 org.junit.Test
|
||||
|
||||
@@ -55,7 +60,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(votes = votes), eventId = null)
|
||||
)
|
||||
@@ -87,7 +92,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
@@ -106,7 +111,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
@@ -136,7 +141,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
|
||||
)
|
||||
@@ -172,7 +177,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
@@ -192,7 +197,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
@@ -221,13 +226,13 @@ internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
private fun aPollContent(
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
votes: Map<String, List<UserId>> = emptyMap(),
|
||||
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
|
||||
endTime: ULong? = null,
|
||||
): PollContent = PollContent(
|
||||
question = A_POLL_QUESTION,
|
||||
kind = pollKind,
|
||||
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,
|
||||
endTime = endTime,
|
||||
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_4 = PollAnswer("id_4", "Hamburger")
|
||||
|
||||
private val MY_USER_WINNING_VOTES = mapOf(
|
||||
A_POLL_ANSWER_1 to listOf(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_3 to emptyList(),
|
||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_10),
|
||||
private val MY_USER_WINNING_VOTES = persistentMapOf(
|
||||
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
|
||||
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 persistentListOf(),
|
||||
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
|
||||
)
|
||||
private val OTHER_WINNING_VOTES = mapOf(
|
||||
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_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6),
|
||||
A_POLL_ANSWER_3 to emptyList(),
|
||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
|
||||
private val OTHER_WINNING_VOTES = persistentMapOf(
|
||||
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 persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
|
||||
A_POLL_ANSWER_3 to persistentListOf(),
|
||||
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@ class TimelineItemGrouperTest {
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
id = computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem.copy("0"),
|
||||
aGroupableItem.copy(id = "1"),
|
||||
).toImmutableList()
|
||||
).toImmutableList(),
|
||||
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -132,20 +133,22 @@ class TimelineItemGrouperTest {
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
id = computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem,
|
||||
aGroupableItem,
|
||||
).toImmutableList()
|
||||
).toImmutableList(),
|
||||
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||
),
|
||||
aNonGroupableItem,
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
id = computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem,
|
||||
aGroupableItem,
|
||||
aGroupableItem,
|
||||
).toImmutableList()
|
||||
).toImmutableList(),
|
||||
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
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.MediaSource
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||
@@ -143,7 +144,7 @@ private fun createDefaultVoiceMessageMediaRepo(
|
||||
url = mxcUri,
|
||||
json = null
|
||||
),
|
||||
mimeType = "audio/ogg",
|
||||
mimeType = MimeTypes.Ogg,
|
||||
body = "someBody.ogg"
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
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.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -287,7 +288,7 @@ private fun createDefaultVoiceMessagePlayer(
|
||||
url = MXC_URI,
|
||||
json = null
|
||||
),
|
||||
mimeType = "audio/ogg",
|
||||
mimeType = MimeTypes.Ogg,
|
||||
body = "someBody.ogg"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
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.timeline.MatrixTimelineItem
|
||||
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.test.FakeMediaPlayer
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
@@ -37,7 +39,7 @@ class RedactedVoiceMessageManagerTest {
|
||||
@Test
|
||||
fun `redacted event - no playing related media`() = runTest {
|
||||
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()
|
||||
}
|
||||
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
||||
@@ -54,7 +56,7 @@ class RedactedVoiceMessageManagerTest {
|
||||
@Test
|
||||
fun `redacted event - playing related media is paused`() = runTest {
|
||||
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()
|
||||
}
|
||||
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
||||
@@ -87,8 +89,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||
isOwn = false,
|
||||
isRemote = false,
|
||||
localSendState = null,
|
||||
reactions = listOf(),
|
||||
receipts = listOf(),
|
||||
reactions = persistentListOf(),
|
||||
receipts = persistentListOf(),
|
||||
sender = A_USER_ID,
|
||||
senderProfile = ProfileTimelineDetails.Unavailable,
|
||||
timestamp = 9442,
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class VoiceMessagePresenterTest {
|
||||
@Test
|
||||
@@ -49,7 +50,7 @@ class VoiceMessagePresenterTest {
|
||||
fun `pressing play downloads and plays`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -87,7 +88,7 @@ class VoiceMessagePresenterTest {
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||
analyticsService = analyticsService,
|
||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -123,7 +124,7 @@ class VoiceMessagePresenterTest {
|
||||
fun `pressing pause while playing pauses`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -172,7 +173,7 @@ class VoiceMessagePresenterTest {
|
||||
fun `seeking before play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(durationMs = 10_000),
|
||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -196,7 +197,7 @@ class VoiceMessagePresenterTest {
|
||||
@Test
|
||||
fun `seeking after play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
content = aTimelineItemVoiceContent(durationMs = 10_000),
|
||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
sealed interface CreatePollEvents {
|
||||
data object Save : CreatePollEvents
|
||||
data class Delete(val confirmed: Boolean) : CreatePollEvents
|
||||
data class SetQuestion(val question: String) : CreatePollEvents
|
||||
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
|
||||
data object AddAnswer : CreatePollEvents
|
||||
|
||||
@@ -18,13 +18,11 @@ package io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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 io.element.android.features.messages.api.MessageComposerContext
|
||||
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.libraries.architecture.Presenter
|
||||
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.isDisclosed
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.launch
|
||||
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(
|
||||
private val repository: PollRepository,
|
||||
private val analyticsService: AnalyticsService,
|
||||
@@ -64,17 +60,31 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||
|
||||
@Composable
|
||||
override fun present(): CreatePollState {
|
||||
var question: String by rememberSaveable { mutableStateOf("") }
|
||||
var answers: List<String> by rememberSaveable { mutableStateOf(listOf("", "")) }
|
||||
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
|
||||
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
// The initial state of the form. In edit mode this will be populated with the poll being edited.
|
||||
var initialPoll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
|
||||
mutableStateOf(PollFormState.Empty)
|
||||
}
|
||||
// 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) {
|
||||
if (mode is CreatePollMode.EditPoll) {
|
||||
repository.getPoll(mode.eventId).onSuccess {
|
||||
question = it.question
|
||||
answers = it.answers.map(PollAnswer::text)
|
||||
pollKind = it.kind
|
||||
val loadedPoll = PollFormState(
|
||||
question = it.question,
|
||||
answers = it.answers.map(PollAnswer::text).toPersistentList(),
|
||||
isDisclosed = it.kind.isDisclosed,
|
||||
)
|
||||
initialPoll = loadedPoll
|
||||
poll = loadedPoll
|
||||
}.onFailure {
|
||||
analyticsService.trackGetPollFailed(it)
|
||||
navigateUp()
|
||||
@@ -82,9 +92,9 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val canSave: Boolean by remember { derivedStateOf { canSave(question, answers) } }
|
||||
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
|
||||
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
|
||||
val canSave: Boolean by remember { derivedStateOf { poll.isValid } }
|
||||
val canAddAnswer: Boolean by remember { derivedStateOf { poll.canAddAnswer } }
|
||||
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { poll.toUiAnswers() } }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -97,14 +107,14 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||
is CreatePollMode.EditPoll -> mode.eventId
|
||||
is CreatePollMode.NewPoll -> null
|
||||
},
|
||||
question = question,
|
||||
answers = answers,
|
||||
pollKind = pollKind,
|
||||
question = poll.question,
|
||||
answers = poll.answers,
|
||||
pollKind = poll.pollKind,
|
||||
maxSelections = MAX_SELECTIONS,
|
||||
).onSuccess {
|
||||
analyticsService.capturePollSaved(
|
||||
isUndisclosed = pollKind == PollKind.Undisclosed,
|
||||
numberOfAnswers = answers.size,
|
||||
isUndisclosed = poll.pollKind == PollKind.Undisclosed,
|
||||
numberOfAnswers = poll.answers.size,
|
||||
)
|
||||
}.onFailure {
|
||||
analyticsService.trackSavePollFailed(it, mode)
|
||||
@@ -114,35 +124,52 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||
Timber.d("Cannot create poll")
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.AddAnswer -> {
|
||||
answers = answers + ""
|
||||
}
|
||||
is CreatePollEvents.RemoveAnswer -> {
|
||||
answers = answers.filterIndexed { index, _ -> index != event.index }
|
||||
}
|
||||
is CreatePollEvents.SetAnswer -> {
|
||||
answers = answers.toMutableList().apply {
|
||||
this[event.index] = event.text.take(MAX_ANSWER_LENGTH)
|
||||
is CreatePollEvents.Delete -> {
|
||||
if (mode !is CreatePollMode.EditPoll) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.confirmed) {
|
||||
showDeleteConfirmation = true
|
||||
return
|
||||
}
|
||||
|
||||
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 -> {
|
||||
pollKind = event.pollKind
|
||||
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
|
||||
}
|
||||
is CreatePollEvents.SetQuestion -> {
|
||||
question = event.question
|
||||
poll = poll.copy(question = event.question)
|
||||
}
|
||||
is CreatePollEvents.NavBack -> {
|
||||
navigateUp()
|
||||
}
|
||||
CreatePollEvents.ConfirmNavBack -> {
|
||||
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() }
|
||||
val shouldConfirm = isDirty
|
||||
if (shouldConfirm) {
|
||||
showConfirmation = true
|
||||
showBackConfirmation = true
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.HideConfirmation -> showConfirmation = false
|
||||
is CreatePollEvents.HideConfirmation -> {
|
||||
showBackConfirmation = false
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +180,11 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||
},
|
||||
canSave = canSave,
|
||||
canAddAnswer = canAddAnswer,
|
||||
question = question,
|
||||
question = poll.question,
|
||||
answers = immutableAnswers,
|
||||
pollKind = pollKind,
|
||||
showConfirmation = showConfirmation,
|
||||
pollKind = poll.pollKind,
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -207,35 +235,12 @@ private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreateP
|
||||
trackError(exception)
|
||||
}
|
||||
|
||||
private fun canSave(
|
||||
question: String,
|
||||
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 ->
|
||||
fun PollFormState.toUiAnswers(): ImmutableList<Answer> {
|
||||
return answers.map { answer ->
|
||||
Answer(
|
||||
text = answer,
|
||||
canDelete = this.size > MIN_ANSWERS,
|
||||
canDelete = canDeleteAnswer,
|
||||
)
|
||||
}.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
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,13 +26,16 @@ data class CreatePollState(
|
||||
val question: String,
|
||||
val answers: ImmutableList<Answer>,
|
||||
val pollKind: PollKind,
|
||||
val showConfirmation: Boolean,
|
||||
val showBackConfirmation: Boolean,
|
||||
val showDeleteConfirmation: Boolean,
|
||||
val eventSink: (CreatePollEvents) -> Unit,
|
||||
) {
|
||||
enum class Mode {
|
||||
New,
|
||||
Edit,
|
||||
}
|
||||
|
||||
val canDelete: Boolean = mode == Mode.Edit
|
||||
}
|
||||
|
||||
data class Answer(
|
||||
|
||||
@@ -34,7 +34,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("", false)
|
||||
),
|
||||
pollKind = PollKind.Disclosed,
|
||||
showConfirmation = false,
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
),
|
||||
aCreatePollState(
|
||||
mode = CreatePollState.Mode.New,
|
||||
@@ -45,7 +46,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = false,
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
aCreatePollState(
|
||||
@@ -57,7 +59,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = true,
|
||||
showBackConfirmation = true,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
aCreatePollState(
|
||||
@@ -71,7 +74,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
||||
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
aCreatePollState(
|
||||
@@ -101,7 +105,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("19", true),
|
||||
Answer("20", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
aCreatePollState(
|
||||
@@ -124,7 +129,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
false
|
||||
),
|
||||
),
|
||||
showConfirmation = false,
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
aCreatePollState(
|
||||
@@ -137,7 +143,21 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
Answer("", false)
|
||||
),
|
||||
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,
|
||||
question: String,
|
||||
answers: PersistentList<Answer>,
|
||||
showConfirmation: Boolean,
|
||||
showBackConfirmation: Boolean,
|
||||
showDeleteConfirmation: Boolean,
|
||||
pollKind: PollKind
|
||||
): CreatePollState {
|
||||
return CreatePollState(
|
||||
@@ -157,7 +178,8 @@ private fun aCreatePollState(
|
||||
canAddAnswer = canAddAnswer,
|
||||
question = question,
|
||||
answers = answers,
|
||||
showConfirmation = showConfirmation,
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
pollKind = pollKind,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -76,11 +76,21 @@ fun CreatePollView(
|
||||
|
||||
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
|
||||
BackHandler(onBack = navBack)
|
||||
if (state.showConfirmation) ConfirmationDialog(
|
||||
content = stringResource(id = R.string.screen_create_poll_discard_confirmation),
|
||||
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
|
||||
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
||||
)
|
||||
if (state.showBackConfirmation) {
|
||||
ConfirmationDialog(
|
||||
content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android),
|
||||
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 answerFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -191,6 +201,13 @@ fun CreatePollView(
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -59,4 +59,11 @@ class PollRepository @Inject constructor(
|
||||
pollKind = pollKind,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deletePoll(
|
||||
pollStartId: EventId,
|
||||
): Result<Unit> =
|
||||
room.redactEvent(
|
||||
eventId = pollStartId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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_headline">"Anonymní hlasování"</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_discard_confirmation_title">"Zrušit 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_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_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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user