Merge branch 'develop' into jonny/timeline-poll-edited
This commit is contained in:
commit
002fd0be66
602 changed files with 5298 additions and 1508 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
|
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text
|
**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
|
||||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -33,12 +33,12 @@ jobs:
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
|
|
|
||||||
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
|
# Ensure we are building the branch and not the branch after being merged on develop
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- uses: actions/setup-java@v3
|
- uses: actions/setup-java@v4
|
||||||
name: Use JDK 17
|
name: Use JDK 17
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
|
|
||||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
|
||||||
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
|
uses: nschloe/action-cached-lfs-checkout@v1.2.2
|
||||||
|
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
@ -57,12 +57,12 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Dependency analysis
|
- name: Dependency analysis
|
||||||
|
|
|
||||||
4
.github/workflows/quality.yml
vendored
4
.github/workflows/quality.yml
vendored
|
|
@ -35,12 +35,12 @@ jobs:
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Run code quality check suite
|
- name: Run code quality check suite
|
||||||
|
|
|
||||||
4
.github/workflows/recordScreenshots.yml
vendored
4
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -33,13 +33,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: ☕️ Use JDK 17
|
- name: ☕️ Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
# Add gradle cache, this should speed up the process
|
# Add gradle cache, this should speed up the process
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Record screenshots
|
- name: Record screenshots
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -20,12 +20,12 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
- name: Create app bundle
|
- name: Create app bundle
|
||||||
env:
|
env:
|
||||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
|
|
|
||||||
4
.github/workflows/sonar.yml
vendored
4
.github/workflows/sonar.yml
vendored
|
|
@ -27,12 +27,12 @@ jobs:
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- name: Use JDK 17
|
- name: Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: 🔊 Publish results to Sonar
|
- name: 🔊 Publish results to Sonar
|
||||||
|
|
|
||||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -39,12 +39,12 @@ jobs:
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- name: ☕️ Use JDK 17
|
- name: ☕️ Use JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.9.0
|
uses: gradle/gradle-build-action@v2.10.0
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class MainNode(
|
||||||
override val daggerComponent = (context as DaggerComponentOwner).daggerComponent
|
override val daggerComponent = (context as DaggerComponentOwner).daggerComponent
|
||||||
|
|
||||||
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
|
||||||
return createNode<RootFlowNode>(context = buildContext)
|
return createNode<RootFlowNode>(buildContext = buildContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
is NavTarget.Room -> {
|
is NavTarget.Room -> {
|
||||||
val callback = object : RoomLoadedFlowNode.Callback {
|
val callback = object : RoomLoadedFlowNode.Callback {
|
||||||
|
override fun onOpenRoom(roomId: RoomId) {
|
||||||
|
backstack.push(NavTarget.Room(roomId))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
coroutineScope.launch { attachRoom(roomId) }
|
coroutineScope.launch { attachRoom(roomId) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||||
), DaggerComponentOwner {
|
), DaggerComponentOwner {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
fun onOpenRoom(roomId: RoomId)
|
||||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
fun onOpenGlobalNotificationSettings()
|
fun onOpenGlobalNotificationSettings()
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +135,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||||
override fun onOpenGlobalNotificationSettings() {
|
override fun onOpenGlobalNotificationSettings() {
|
||||||
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
|
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenRoom(roomId: RoomId) {
|
||||||
|
callbacks.forEach { it.onOpenRoom(roomId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
|
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
|
||||||
.params(RoomDetailsEntryPoint.Params(initialTarget))
|
.params(RoomDetailsEntryPoint.Params(initialTarget))
|
||||||
|
|
|
||||||
|
|
@ -236,11 +236,11 @@ koverMerged {
|
||||||
name = "Global minimum code coverage."
|
name = "Global minimum code coverage."
|
||||||
target = kotlinx.kover.api.VerificationTarget.ALL
|
target = kotlinx.kover.api.VerificationTarget.ALL
|
||||||
bound {
|
bound {
|
||||||
minValue = 60
|
minValue = 65
|
||||||
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
||||||
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
|
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
|
||||||
// minValue to 25 and maxValue to 35.
|
// minValue to 25 and maxValue to 35.
|
||||||
maxValue = 70
|
maxValue = 75
|
||||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||||
}
|
}
|
||||||
|
|
@ -376,3 +376,22 @@ subprojects {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
kotlinOptions {
|
||||||
|
if (project.findProperty("composeCompilerReports") == "true") {
|
||||||
|
freeCompilerArgs += listOf(
|
||||||
|
"-P",
|
||||||
|
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (project.findProperty("composeCompilerMetrics") == "true") {
|
||||||
|
freeCompilerArgs += listOf(
|
||||||
|
"-P",
|
||||||
|
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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.mediaupload.test)
|
||||||
testImplementation(projects.libraries.permissions.test)
|
testImplementation(projects.libraries.permissions.test)
|
||||||
testImplementation(projects.libraries.usersearch.test)
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
|
testImplementation(projects.features.createroom.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,11 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
|
||||||
backstack.push(NavTarget.ConfigureRoom)
|
backstack.push(NavTarget.ConfigureRoom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<AddPeopleNode>(context = buildContext, plugins = listOf(callback))
|
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.ConfigureRoom -> {
|
NavTarget.ConfigureRoom -> {
|
||||||
val callbacks = plugins<ConfigureRoomNode.Callback>()
|
val callbacks = plugins<ConfigureRoomNode.Callback>()
|
||||||
createNode<ConfigureRoomNode>(context = buildContext, plugins = callbacks)
|
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
||||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<CreateRoomRootNode>(context = buildContext, plugins = listOf(callback))
|
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.NewRoom -> {
|
NavTarget.NewRoom -> {
|
||||||
val callback = object : ConfigureRoomNode.Callback {
|
val callback = object : ConfigureRoomNode.Callback {
|
||||||
|
|
@ -80,7 +80,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
||||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<ConfigureRoomFlowNode>(context = buildContext, plugins = listOf(callback))
|
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
import io.element.android.features.createroom.api.StartDMAction
|
||||||
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListPresenter
|
import io.element.android.features.createroom.impl.userlist.UserListPresenter
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
|
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import io.element.android.libraries.usersearch.api.UserRepository
|
import io.element.android.libraries.usersearch.api.UserRepository
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -43,8 +38,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
presenterFactory: UserListPresenter.Factory,
|
presenterFactory: UserListPresenter.Factory,
|
||||||
userRepository: UserRepository,
|
userRepository: UserRepository,
|
||||||
userListDataStore: UserListDataStore,
|
userListDataStore: UserListDataStore,
|
||||||
private val matrixClient: MatrixClient,
|
private val startDMAction: StartDMAction,
|
||||||
private val analyticsService: AnalyticsService,
|
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
) : Presenter<CreateRoomRootState> {
|
) : Presenter<CreateRoomRootState> {
|
||||||
|
|
||||||
|
|
@ -61,37 +55,22 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
val userListState = presenter.present()
|
val userListState = presenter.present()
|
||||||
|
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
val startDmActionState: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
||||||
fun handleEvents(event: CreateRoomRootEvents) {
|
fun handleEvents(event: CreateRoomRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction)
|
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
|
||||||
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
|
startDMAction.execute(event.matrixUser.userId, startDmActionState)
|
||||||
|
}
|
||||||
|
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = Async.Uninitialized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateRoomRootState(
|
return CreateRoomRootState(
|
||||||
applicationName = buildMeta.applicationName,
|
applicationName = buildMeta.applicationName,
|
||||||
userListState = userListState,
|
userListState = userListState,
|
||||||
startDmAction = startDmAction.value,
|
startDmAction = startDmActionState.value,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
|
||||||
suspend {
|
|
||||||
matrixClient.findDM(matrixUser.userId).use { existingDM ->
|
|
||||||
existingDM?.roomId ?: createDM(matrixUser)
|
|
||||||
}
|
|
||||||
}.runCatchingUpdatingState(startDmAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createDM(user: MatrixUser): RoomId {
|
|
||||||
return matrixClient
|
|
||||||
.createDM(user.userId)
|
|
||||||
.onSuccess {
|
|
||||||
analyticsService.capture(CreatedRoom(isDM = true))
|
|
||||||
}
|
|
||||||
.getOrThrow()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
import io.element.android.features.createroom.api.StartDMAction
|
||||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
||||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
import io.element.android.features.createroom.test.FakeStartDMAction
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
|
||||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -47,142 +43,57 @@ class CreateRoomRootPresenterTests {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
private lateinit var userRepository: FakeUserRepository
|
|
||||||
private lateinit var presenter: CreateRoomRootPresenter
|
|
||||||
private lateinit var fakeUserListPresenter: FakeUserListPresenter
|
|
||||||
private lateinit var fakeMatrixClient: FakeMatrixClient
|
|
||||||
private lateinit var fakeAnalyticsService: FakeAnalyticsService
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
fakeUserListPresenter = FakeUserListPresenter()
|
|
||||||
fakeMatrixClient = FakeMatrixClient()
|
|
||||||
fakeAnalyticsService = FakeAnalyticsService()
|
|
||||||
userRepository = FakeUserRepository()
|
|
||||||
presenter = CreateRoomRootPresenter(
|
|
||||||
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
|
|
||||||
userRepository = userRepository,
|
|
||||||
userListDataStore = UserListDataStore(),
|
|
||||||
matrixClient = fakeMatrixClient,
|
|
||||||
analyticsService = fakeAnalyticsService,
|
|
||||||
buildMeta = aBuildMeta(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - start DM action complete scenario`() = runTest {
|
||||||
|
val startDMAction = FakeStartDMAction()
|
||||||
|
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||||
assertThat(initialState.userListState.isSearchActive).isFalse()
|
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||||
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - trigger create DM action`() = runTest {
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||||
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
|
val startDMSuccessResult = Async.Success(A_ROOM_ID)
|
||||||
|
val startDMFailureResult = Async.Failure<RoomId>(A_THROWABLE)
|
||||||
fakeMatrixClient.givenFindDmResult(null)
|
|
||||||
fakeMatrixClient.givenCreateDmResult(createDmResult)
|
|
||||||
|
|
||||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
|
||||||
val stateAfterStartDM = awaitItem()
|
|
||||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
|
||||||
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - creating a DM records analytics event`() = runTest {
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
|
||||||
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
|
|
||||||
|
|
||||||
fakeMatrixClient.givenFindDmResult(null)
|
|
||||||
fakeMatrixClient.givenCreateDmResult(createDmResult)
|
|
||||||
|
|
||||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
|
||||||
skipItems(2)
|
|
||||||
|
|
||||||
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
|
|
||||||
assertThat(analyticsEvent).isNotNull()
|
|
||||||
assertThat(analyticsEvent?.isDM).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - trigger retrieve DM action`() = runTest {
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
|
||||||
val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
|
|
||||||
|
|
||||||
fakeMatrixClient.givenFindDmResult(fakeDmResult)
|
|
||||||
|
|
||||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
|
||||||
val stateAfterStartDM = awaitItem()
|
|
||||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
|
||||||
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - trigger retry create DM action`() = runTest {
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
|
||||||
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
|
|
||||||
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
|
|
||||||
|
|
||||||
fakeMatrixClient.givenFindDmResult(null)
|
|
||||||
fakeMatrixClient.givenCreateDmError(A_THROWABLE)
|
|
||||||
fakeMatrixClient.givenCreateDmResult(createDmResult)
|
|
||||||
|
|
||||||
// Failure
|
// Failure
|
||||||
|
startDMAction.givenExecuteResult(startDMFailureResult)
|
||||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val stateAfterStartDM = awaitItem()
|
awaitItem().also { state ->
|
||||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
|
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel
|
// Success
|
||||||
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
|
startDMAction.givenExecuteResult(startDMSuccessResult)
|
||||||
val stateAfterCancel = awaitItem()
|
awaitItem().also { state ->
|
||||||
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
assertThat(state.startDmAction).isEqualTo(Async.Uninitialized)
|
||||||
|
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||||
// Failure
|
}
|
||||||
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val stateAfterSecondAttempt = awaitItem()
|
awaitItem().also { state ->
|
||||||
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
|
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
}
|
||||||
|
|
||||||
// Retry with success
|
|
||||||
fakeMatrixClient.givenCreateDmError(null)
|
|
||||||
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
|
||||||
val stateAfterRetryStartDM = awaitItem()
|
|
||||||
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
|
||||||
assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createCreateRoomRootPresenter(
|
||||||
|
startDMAction: StartDMAction = FakeStartDMAction(),
|
||||||
|
): CreateRoomRootPresenter {
|
||||||
|
return CreateRoomRootPresenter(
|
||||||
|
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
|
||||||
|
userRepository = FakeUserRepository(),
|
||||||
|
userListDataStore = UserListDataStore(),
|
||||||
|
startDMAction = startDMAction,
|
||||||
|
buildMeta = aBuildMeta(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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) {
|
return when (this) {
|
||||||
BackupUploadState.Unknown,
|
BackupUploadState.Unknown,
|
||||||
BackupUploadState.Waiting,
|
BackupUploadState.Waiting,
|
||||||
is BackupUploadState.Uploading,
|
is BackupUploadState.Uploading -> true
|
||||||
is BackupUploadState.CheckingIfUploadNeeded -> true
|
|
||||||
is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection
|
is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection
|
||||||
BackupUploadState.Done,
|
BackupUploadState.Done,
|
||||||
BackupUploadState.Error -> false
|
BackupUploadState.Error -> false
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.messages.impl"
|
namespace = "io.element.android.features.messages.impl"
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
anvil {
|
anvil {
|
||||||
|
|
@ -48,6 +53,7 @@ dependencies {
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.libraries.eventformatter.api)
|
implementation(projects.libraries.eventformatter.api)
|
||||||
implementation(projects.libraries.mediapickers.api)
|
implementation(projects.libraries.mediapickers.api)
|
||||||
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.mediaupload.api)
|
implementation(projects.libraries.mediaupload.api)
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
|
|
@ -87,7 +93,10 @@ dependencies {
|
||||||
testImplementation(projects.libraries.textcomposer.test)
|
testImplementation(projects.libraries.textcomposer.test)
|
||||||
testImplementation(projects.libraries.voicerecorder.test)
|
testImplementation(projects.libraries.voicerecorder.test)
|
||||||
testImplementation(projects.libraries.mediaplayer.test)
|
testImplementation(projects.libraries.mediaplayer.test)
|
||||||
|
testImplementation(projects.libraries.mediaviewer.test)
|
||||||
testImplementation(libs.test.mockk)
|
testImplementation(libs.test.mockk)
|
||||||
|
testImplementation(libs.test.junitext)
|
||||||
|
testImplementation(libs.test.robolectric)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||||
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
|
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
|
||||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
|
||||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
@ -62,6 +60,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -180,6 +180,8 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
mediaInfo = navTarget.mediaInfo,
|
mediaInfo = navTarget.mediaInfo,
|
||||||
mediaSource = navTarget.mediaSource,
|
mediaSource = navTarget.mediaSource,
|
||||||
thumbnailSource = navTarget.thumbnailSource,
|
thumbnailSource = navTarget.thumbnailSource,
|
||||||
|
canDownload = true,
|
||||||
|
canShare = true,
|
||||||
)
|
)
|
||||||
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
import io.element.android.libraries.matrix.ui.room.canRedactAsState
|
import io.element.android.libraries.matrix.ui.room.canRedactAsState
|
||||||
|
|
@ -108,6 +109,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val featureFlagsService: FeatureFlagService,
|
private val featureFlagsService: FeatureFlagService,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||||
|
|
@ -144,6 +146,16 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canJoinCall by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(currentSessionIdHolder.current) {
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
canJoinCall = room.canUserJoinCall(userId = currentSessionIdHolder.current).getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
||||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
|
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
|
||||||
|
|
@ -162,8 +174,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||||
|
|
||||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||||
// TODO add min power level to use this feature in the future?
|
|
||||||
val enableInRoomCalls = true
|
|
||||||
LaunchedEffect(featureFlagsService) {
|
LaunchedEffect(featureFlagsService) {
|
||||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +203,12 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val callState = when {
|
||||||
|
!canJoinCall -> RoomCallState.DISABLED
|
||||||
|
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
|
||||||
|
else -> RoomCallState.ENABLED
|
||||||
|
}
|
||||||
|
|
||||||
return MessagesState(
|
return MessagesState(
|
||||||
roomId = room.roomId,
|
roomId = room.roomId,
|
||||||
roomName = roomName,
|
roomName = roomName,
|
||||||
|
|
@ -213,9 +229,8 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
inviteProgress = inviteProgress.value,
|
inviteProgress = inviteProgress.value,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
enableInRoomCalls = enableInRoomCalls,
|
|
||||||
appName = buildMeta.applicationName,
|
appName = buildMeta.applicationName,
|
||||||
isCallOngoing = roomInfo?.hasRoomCall ?: false,
|
callState = callState,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +239,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
return AvatarData(
|
return AvatarData(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
url = avatarUrl,
|
url = avatarUrl ?: room.avatarUrl,
|
||||||
size = AvatarSize.TimelineRoom
|
size = AvatarSize.TimelineRoom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,13 @@ data class MessagesState(
|
||||||
val showReinvitePrompt: Boolean,
|
val showReinvitePrompt: Boolean,
|
||||||
val enableTextFormatting: Boolean,
|
val enableTextFormatting: Boolean,
|
||||||
val enableVoiceMessages: Boolean,
|
val enableVoiceMessages: Boolean,
|
||||||
val enableInRoomCalls: Boolean,
|
val callState: RoomCallState,
|
||||||
val isCallOngoing: Boolean,
|
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class RoomCallState {
|
||||||
|
ENABLED,
|
||||||
|
ONGOING,
|
||||||
|
DISABLED
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aMessagesState().copy(
|
aMessagesState().copy(
|
||||||
isCallOngoing = true,
|
callState = RoomCallState.ONGOING,
|
||||||
),
|
),
|
||||||
aMessagesState().copy(
|
aMessagesState().copy(
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
|
|
@ -75,6 +75,9 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
showSendFailureDialog = true
|
showSendFailureDialog = true
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
aMessagesState().copy(
|
||||||
|
callState = RoomCallState.DISABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,8 +120,7 @@ fun aMessagesState() = MessagesState(
|
||||||
showReinvitePrompt = false,
|
showReinvitePrompt = false,
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
enableInRoomCalls = true,
|
callState = RoomCallState.ENABLED,
|
||||||
isCallOngoing = false,
|
|
||||||
appName = "Element",
|
appName = "Element",
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -191,10 +191,9 @@ fun MessagesView(
|
||||||
MessagesViewTopBar(
|
MessagesViewTopBar(
|
||||||
roomName = state.roomName.dataOrNull(),
|
roomName = state.roomName.dataOrNull(),
|
||||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||||
inRoomCallsEnabled = state.enableInRoomCalls,
|
callState = state.callState,
|
||||||
onBackPressed = onBackPressed,
|
onBackPressed = onBackPressed,
|
||||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||||
isCallOngoing = state.isCallOngoing,
|
|
||||||
onJoinCallClicked = onJoinCallClicked,
|
onJoinCallClicked = onJoinCallClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -449,8 +448,7 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
private fun MessagesViewTopBar(
|
private fun MessagesViewTopBar(
|
||||||
roomName: String?,
|
roomName: String?,
|
||||||
roomAvatar: AvatarData?,
|
roomAvatar: AvatarData?,
|
||||||
inRoomCallsEnabled: Boolean,
|
callState: RoomCallState,
|
||||||
isCallOngoing: Boolean,
|
|
||||||
onRoomDetailsClicked: () -> Unit,
|
onRoomDetailsClicked: () -> Unit,
|
||||||
onJoinCallClicked: () -> Unit,
|
onJoinCallClicked: () -> Unit,
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
|
|
@ -477,13 +475,11 @@ private fun MessagesViewTopBar(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (inRoomCallsEnabled) {
|
if (callState == RoomCallState.ONGOING) {
|
||||||
if (isCallOngoing) {
|
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
|
||||||
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
|
} else {
|
||||||
} else {
|
IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) {
|
||||||
IconButton(onClick = onJoinCallClicked) {
|
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
|
||||||
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.attachments
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
@ -114,6 +115,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
sendActionState.value = SendActionState.Done
|
sendActionState.value = SendActionState.Done
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
|
Timber.e(error, "Failed to send attachment")
|
||||||
if (error is CancellationException) {
|
if (error is CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||||
import io.element.android.features.messages.impl.media.local.aFileInfo
|
import io.element.android.libraries.mediaviewer.api.local.aFileInfo
|
||||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
import io.element.android.libraries.mediaviewer.api.local.anImageInfo
|
||||||
|
|
||||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||||
override val values: Sequence<AttachmentsPreviewState>
|
override val values: Sequence<AttachmentsPreviewState>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
||||||
|
|
@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
|
||||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -51,6 +50,7 @@ import io.element.android.libraries.matrix.api.room.Mention
|
||||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.textcomposer.model.Message
|
import io.element.android.libraries.textcomposer.model.Message
|
||||||
|
|
@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -432,6 +433,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
attachmentState.value = AttachmentsState.None
|
attachmentState.value = AttachmentsState.None
|
||||||
}
|
}
|
||||||
.onFailure { cause ->
|
.onFailure { cause ->
|
||||||
|
Timber.e(cause, "Failed to send attachment")
|
||||||
attachmentState.value = AttachmentsState.None
|
attachmentState.value = AttachmentsState.None
|
||||||
if (cause is CancellationException) {
|
if (cause is CancellationException) {
|
||||||
throw cause
|
throw cause
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import im.vector.app.features.analytics.plan.PollEnd
|
||||||
import im.vector.app.features.analytics.plan.PollVote
|
import im.vector.app.features.analytics.plan.PollVote
|
||||||
import io.element.android.features.messages.impl.MessagesNavigator
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||||
|
|
@ -98,7 +99,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
|
|
||||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val hasNewItems = remember { mutableStateOf(false) }
|
val newItemState = remember { mutableStateOf(NewEventState.None) }
|
||||||
|
|
||||||
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
|
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
|
||||||
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
|
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||||
|
|
@ -121,7 +122,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
is TimelineEvents.OnScrollFinished -> {
|
is TimelineEvents.OnScrollFinished -> {
|
||||||
if (event.firstIndex == 0) {
|
if (event.firstIndex == 0) {
|
||||||
hasNewItems.value = false
|
newItemState.value = NewEventState.None
|
||||||
}
|
}
|
||||||
appScope.sendReadReceiptIfNeeded(
|
appScope.sendReadReceiptIfNeeded(
|
||||||
firstVisibleIndex = event.firstIndex,
|
firstVisibleIndex = event.firstIndex,
|
||||||
|
|
@ -150,7 +151,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(timelineItems.size) {
|
LaunchedEffect(timelineItems.size) {
|
||||||
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
|
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -182,7 +183,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
showReadReceipts = readReceiptsEnabled,
|
showReadReceipts = readReceiptsEnabled,
|
||||||
hasNewItems = hasNewItems.value,
|
newEventState = newItemState.value,
|
||||||
sessionState = sessionState,
|
sessionState = sessionState,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
|
|
@ -191,22 +192,32 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
/**
|
/**
|
||||||
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
||||||
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
||||||
* The state never goes back to false from this method, but need to be reset from somewhere else.
|
* The state never goes back to None from this method, but need to be reset from somewhere else.
|
||||||
*/
|
*/
|
||||||
private suspend fun computeHasNewItems(
|
private suspend fun computeNewItemState(
|
||||||
timelineItems: ImmutableList<TimelineItem>,
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
prevMostRecentItemId: MutableState<String?>,
|
prevMostRecentItemId: MutableState<String?>,
|
||||||
hasNewItemsState: MutableState<Boolean>
|
newEventState: MutableState<NewEventState>
|
||||||
) = withContext(dispatchers.computation) {
|
) = withContext(dispatchers.computation) {
|
||||||
|
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
|
||||||
|
if (newEventState.value == NewEventState.FromMe) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
val newMostRecentItem = timelineItems.firstOrNull()
|
val newMostRecentItem = timelineItems.firstOrNull()
|
||||||
val prevMostRecentItemIdValue = prevMostRecentItemId.value
|
val prevMostRecentItemIdValue = prevMostRecentItemId.value
|
||||||
val newMostRecentItemId = newMostRecentItem?.identifier()
|
val newMostRecentItemId = newMostRecentItem?.identifier()
|
||||||
val hasNewItems = prevMostRecentItemIdValue != null &&
|
val hasNewEvent = prevMostRecentItemIdValue != null &&
|
||||||
newMostRecentItem is TimelineItem.Event &&
|
newMostRecentItem is TimelineItem.Event &&
|
||||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
newMostRecentItemId != prevMostRecentItemIdValue
|
newMostRecentItemId != prevMostRecentItemIdValue
|
||||||
if (hasNewItems) {
|
if (hasNewEvent) {
|
||||||
hasNewItemsState.value = true
|
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
|
||||||
|
val fromMe = newMostRecentEvent?.localSendState != null
|
||||||
|
newEventState.value = if (fromMe) {
|
||||||
|
NewEventState.FromMe
|
||||||
|
} else {
|
||||||
|
NewEventState.FromOther
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevMostRecentItemId.value = newMostRecentItemId
|
prevMostRecentItemId.value = newMostRecentItemId
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -30,7 +31,7 @@ data class TimelineState(
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
val userHasPermissionToSendMessage: Boolean,
|
val userHasPermissionToSendMessage: Boolean,
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
val hasNewItems: Boolean,
|
val newEventState: NewEventState,
|
||||||
val sessionState: SessionState,
|
val sessionState: SessionState,
|
||||||
val eventSink: (TimelineEvents) -> Unit
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
|
|
@ -53,7 +55,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
||||||
),
|
),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
userHasPermissionToSendMessage = true,
|
userHasPermissionToSendMessage = true,
|
||||||
hasNewItems = false,
|
newEventState = NewEventState.None,
|
||||||
sessionState = aSessionState(
|
sessionState = aSessionState(
|
||||||
isSessionVerified = true,
|
isSessionVerified = true,
|
||||||
isKeyBackupEnabled = true,
|
isKeyBackupEnabled = true,
|
||||||
|
|
@ -186,17 +188,27 @@ internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
internal fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||||
val event = aTimelineItemEvent(
|
val event1 = aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = aTimelineItemStateEventContent(),
|
content = aTimelineItemStateEventContent(),
|
||||||
groupPosition = TimelineItemGroupPosition.None
|
groupPosition = TimelineItemGroupPosition.None,
|
||||||
|
readReceiptState = TimelineItemReadReceipts(
|
||||||
|
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
val event2 = aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemStateEventContent(body = "Another state event"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.None,
|
||||||
|
readReceiptState = TimelineItemReadReceipts(
|
||||||
|
receipts = listOf(aReadReceiptData(1)).toPersistentList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val events = listOf(event1, event2)
|
||||||
return TimelineItem.GroupedEvents(
|
return TimelineItem.GroupedEvents(
|
||||||
id = id.toString(),
|
id = id.toString(),
|
||||||
events = listOf(
|
events = events.toImmutableList(),
|
||||||
event,
|
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
|
||||||
event,
|
|
||||||
).toImmutableList()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,11 @@ package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -42,36 +40,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
|
|
||||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
|
|
||||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
|
|
||||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
|
|
||||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
|
||||||
import io.element.android.libraries.designsystem.animation.alphaAnimation
|
import io.element.android.libraries.designsystem.animation.alphaAnimation
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -167,141 +156,44 @@ fun TimelineView(
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
hasNewItems = state.hasNewItems,
|
newEventState = state.newEventState,
|
||||||
onScrollFinishedAt = ::onScrollFinishedAt
|
onScrollFinishedAt = ::onScrollFinishedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TimelineItemRow(
|
|
||||||
timelineItem: TimelineItem,
|
|
||||||
showReadReceipts: Boolean,
|
|
||||||
isLastOutgoingMessage: Boolean,
|
|
||||||
highlightedItem: String?,
|
|
||||||
userHasPermissionToSendMessage: Boolean,
|
|
||||||
sessionState: SessionState,
|
|
||||||
onUserDataClick: (UserId) -> Unit,
|
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
|
||||||
inReplyToClick: (EventId) -> Unit,
|
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
|
||||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
|
||||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
|
||||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
|
||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
when (timelineItem) {
|
|
||||||
is TimelineItem.Virtual -> {
|
|
||||||
TimelineItemVirtualRow(
|
|
||||||
virtual = timelineItem,
|
|
||||||
sessionState = sessionState,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is TimelineItem.Event -> {
|
|
||||||
if (timelineItem.content is TimelineItemStateContent) {
|
|
||||||
TimelineItemStateEventRow(
|
|
||||||
event = timelineItem,
|
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
|
||||||
onClick = { onClick(timelineItem) },
|
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
|
||||||
eventSink = eventSink,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
TimelineItemEventRow(
|
|
||||||
event = timelineItem,
|
|
||||||
showReadReceipts = showReadReceipts,
|
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
|
||||||
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
|
|
||||||
onClick = { onClick(timelineItem) },
|
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
|
||||||
onUserDataClick = onUserDataClick,
|
|
||||||
inReplyToClick = inReplyToClick,
|
|
||||||
onReactionClick = onReactionClick,
|
|
||||||
onReactionLongClick = onReactionLongClick,
|
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
|
||||||
onReadReceiptClick = onReadReceiptClick,
|
|
||||||
onTimestampClicked = onTimestampClicked,
|
|
||||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
|
||||||
eventSink = eventSink,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is TimelineItem.GroupedEvents -> {
|
|
||||||
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun onExpandGroupClick() {
|
|
||||||
isExpanded.value = !isExpanded.value
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = modifier.animateContentSize()) {
|
|
||||||
GroupHeaderView(
|
|
||||||
text = pluralStringResource(
|
|
||||||
id = R.plurals.room_timeline_state_changes,
|
|
||||||
count = timelineItem.events.size,
|
|
||||||
timelineItem.events.size
|
|
||||||
),
|
|
||||||
isExpanded = isExpanded.value,
|
|
||||||
isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem },
|
|
||||||
onClick = ::onExpandGroupClick,
|
|
||||||
)
|
|
||||||
if (isExpanded.value) {
|
|
||||||
Column {
|
|
||||||
timelineItem.events.forEach { subGroupEvent ->
|
|
||||||
TimelineItemRow(
|
|
||||||
timelineItem = subGroupEvent,
|
|
||||||
showReadReceipts = showReadReceipts,
|
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
|
||||||
highlightedItem = highlightedItem,
|
|
||||||
sessionState = sessionState,
|
|
||||||
userHasPermissionToSendMessage = false,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
inReplyToClick = inReplyToClick,
|
|
||||||
onUserDataClick = onUserDataClick,
|
|
||||||
onTimestampClicked = onTimestampClicked,
|
|
||||||
onReactionClick = onReactionClick,
|
|
||||||
onReactionLongClick = onReactionLongClick,
|
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
|
||||||
onReadReceiptClick = onReadReceiptClick,
|
|
||||||
eventSink = eventSink,
|
|
||||||
onSwipeToReply = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoxScope.TimelineScrollHelper(
|
private fun BoxScope.TimelineScrollHelper(
|
||||||
isTimelineEmpty: Boolean,
|
isTimelineEmpty: Boolean,
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
hasNewItems: Boolean,
|
newEventState: NewEventState,
|
||||||
onScrollFinishedAt: (Int) -> Unit,
|
onScrollFinishedAt: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
|
val canAutoScroll by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
lazyListState.firstVisibleItemIndex < 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(canAutoScroll, hasNewItems) {
|
fun scrollToBottom() {
|
||||||
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
|
coroutineScope.launch {
|
||||||
if (shouldAutoScroll) {
|
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||||
coroutineScope.launch {
|
lazyListState.scrollToItem(0)
|
||||||
|
} else {
|
||||||
lazyListState.animateScrollToItem(0)
|
lazyListState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(canAutoScroll, newEventState) {
|
||||||
|
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
|
||||||
|
if (shouldAutoScroll) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
|
||||||
if (isScrollFinished && !isTimelineEmpty) {
|
if (isScrollFinished && !isTimelineEmpty) {
|
||||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||||
|
|
@ -315,15 +207,7 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
onClick = {
|
onClick = ::scrollToBottom,
|
||||||
coroutineScope.launch {
|
|
||||||
if (lazyListState.firstVisibleItemIndex > 10) {
|
|
||||||
lazyListState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.constraintlayout.compose.ConstrainScope
|
import androidx.constraintlayout.compose.ConstrainScope
|
||||||
import androidx.constraintlayout.compose.ConstraintLayout
|
import androidx.constraintlayout.compose.ConstraintLayout
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
|
|
@ -66,6 +67,7 @@ import io.element.android.features.messages.impl.timeline.components.event.toExt
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.InReplyToMetadata
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||||
|
|
@ -75,6 +77,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.metadata
|
||||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
|
@ -89,18 +92,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
@ -171,8 +163,6 @@ fun TimelineItemEventRow(
|
||||||
state = state.draggableState,
|
state = state.draggableState,
|
||||||
),
|
),
|
||||||
event = event,
|
event = event,
|
||||||
showReadReceipts = showReadReceipts,
|
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -183,7 +173,6 @@ fun TimelineItemEventRow(
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -191,8 +180,6 @@ fun TimelineItemEventRow(
|
||||||
} else {
|
} else {
|
||||||
TimelineItemEventRowContent(
|
TimelineItemEventRowContent(
|
||||||
event = event,
|
event = event,
|
||||||
showReadReceipts = showReadReceipts,
|
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -203,10 +190,20 @@ fun TimelineItemEventRow(
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Read receipts / Send state
|
||||||
|
TimelineItemReadReceiptView(
|
||||||
|
state = ReadReceiptViewState(
|
||||||
|
sendState = event.localSendState,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
|
receipts = event.readReceiptState.receipts,
|
||||||
|
),
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,8 +233,6 @@ private fun SwipeSensitivity(
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineItemEventRowContent(
|
private fun TimelineItemEventRowContent(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
showReadReceipts: Boolean,
|
|
||||||
isLastOutgoingMessage: Boolean,
|
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
@ -246,7 +241,6 @@ private fun TimelineItemEventRowContent(
|
||||||
inReplyToClicked: () -> Unit,
|
inReplyToClicked: () -> Unit,
|
||||||
onUserDataClicked: () -> Unit,
|
onUserDataClicked: () -> Unit,
|
||||||
onReactionClicked: (emoji: String) -> Unit,
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
onReadReceiptsClicked: () -> Unit,
|
|
||||||
onReactionLongClicked: (emoji: String) -> Unit,
|
onReactionLongClicked: (emoji: String) -> Unit,
|
||||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
|
|
@ -267,7 +261,6 @@ private fun TimelineItemEventRowContent(
|
||||||
sender,
|
sender,
|
||||||
message,
|
message,
|
||||||
reactions,
|
reactions,
|
||||||
readReceipts,
|
|
||||||
) = createRefs()
|
) = createRefs()
|
||||||
|
|
||||||
// Sender
|
// Sender
|
||||||
|
|
@ -334,25 +327,6 @@ private fun TimelineItemEventRowContent(
|
||||||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read receipts / Send state
|
|
||||||
TimelineItemReadReceiptView(
|
|
||||||
state = ReadReceiptViewState(
|
|
||||||
sendState = event.localSendState,
|
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
|
||||||
receipts = event.readReceiptState.receipts,
|
|
||||||
),
|
|
||||||
showReadReceipts = showReadReceipts,
|
|
||||||
onReadReceiptsClicked = onReadReceiptsClicked,
|
|
||||||
modifier = Modifier
|
|
||||||
.constrainAs(readReceipts) {
|
|
||||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
|
||||||
top.linkTo(reactions.bottom, margin = 4.dp)
|
|
||||||
} else {
|
|
||||||
top.linkTo(message.bottom, margin = 4.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,13 +512,10 @@ private fun MessageEventBubbleContent(
|
||||||
}
|
}
|
||||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||||
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
|
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
|
||||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
|
|
||||||
val text = textForInReplyTo(inReplyTo)
|
|
||||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||||
ReplyToContent(
|
ReplyToContent(
|
||||||
senderName = senderName,
|
senderName = senderName,
|
||||||
text = text,
|
metadata = inReplyTo.metadata(),
|
||||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
|
@ -585,11 +556,10 @@ private fun MessageEventBubbleContent(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyToContent(
|
private fun ReplyToContent(
|
||||||
senderName: String,
|
senderName: String,
|
||||||
text: String?,
|
metadata: InReplyToMetadata?,
|
||||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val paddings = if (attachmentThumbnailInfo != null) {
|
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
|
||||||
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
|
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
|
||||||
} else {
|
} else {
|
||||||
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
|
@ -599,9 +569,9 @@ private fun ReplyToContent(
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.padding(paddings)
|
.padding(paddings)
|
||||||
) {
|
) {
|
||||||
if (attachmentThumbnailInfo != null) {
|
if (metadata is InReplyToMetadata.Thumbnail) {
|
||||||
AttachmentThumbnail(
|
AttachmentThumbnail(
|
||||||
info = attachmentThumbnailInfo,
|
info = metadata.attachmentThumbnailInfo,
|
||||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
|
|
@ -619,7 +589,7 @@ private fun ReplyToContent(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = text.orEmpty(),
|
text = metadata?.text.orEmpty(),
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
textAlign = TextAlign.Start,
|
textAlign = TextAlign.Start,
|
||||||
color = ElementTheme.materialColors.secondary,
|
color = ElementTheme.materialColors.secondary,
|
||||||
|
|
@ -630,60 +600,6 @@ private fun ReplyToContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? {
|
|
||||||
return when (val eventContent = inReplyTo.eventContent) {
|
|
||||||
is MessageContent -> when (val type = eventContent.type) {
|
|
||||||
is ImageMessageType -> AttachmentThumbnailInfo(
|
|
||||||
thumbnailSource = type.info?.thumbnailSource ?: type.source,
|
|
||||||
textContent = eventContent.body,
|
|
||||||
type = AttachmentThumbnailType.Image,
|
|
||||||
blurHash = type.info?.blurhash,
|
|
||||||
)
|
|
||||||
is VideoMessageType -> AttachmentThumbnailInfo(
|
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
|
||||||
textContent = eventContent.body,
|
|
||||||
type = AttachmentThumbnailType.Video,
|
|
||||||
blurHash = type.info?.blurhash,
|
|
||||||
)
|
|
||||||
is FileMessageType -> AttachmentThumbnailInfo(
|
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
|
||||||
textContent = eventContent.body,
|
|
||||||
type = AttachmentThumbnailType.File,
|
|
||||||
)
|
|
||||||
is LocationMessageType -> AttachmentThumbnailInfo(
|
|
||||||
textContent = eventContent.body,
|
|
||||||
type = AttachmentThumbnailType.Location,
|
|
||||||
)
|
|
||||||
is AudioMessageType -> AttachmentThumbnailInfo(
|
|
||||||
textContent = eventContent.body,
|
|
||||||
type = AttachmentThumbnailType.Audio,
|
|
||||||
)
|
|
||||||
is VoiceMessageType -> AttachmentThumbnailInfo(
|
|
||||||
type = AttachmentThumbnailType.Voice,
|
|
||||||
)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
is PollContent -> AttachmentThumbnailInfo(
|
|
||||||
textContent = eventContent.question,
|
|
||||||
type = AttachmentThumbnailType.Poll,
|
|
||||||
)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun textForInReplyTo(inReplyTo: InReplyToDetails): String {
|
|
||||||
return when (val eventContent = inReplyTo.eventContent) {
|
|
||||||
is MessageContent -> when (eventContent.type) {
|
|
||||||
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
|
|
||||||
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
|
|
||||||
else -> inReplyTo.textContent ?: eventContent.body
|
|
||||||
}
|
|
||||||
is PollContent -> eventContent.question
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -127,9 +129,9 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
||||||
question = "Poll which are being replied.",
|
question = "Poll which are being replied.",
|
||||||
kind = PollKind.Disclosed,
|
kind = PollKind.Disclosed,
|
||||||
maxSelections = 1u,
|
maxSelections = 1u,
|
||||||
answers = emptyList(),
|
answers = persistentListOf(),
|
||||||
votes = emptyMap(),
|
votes = persistentMapOf(),
|
||||||
endTime = null,
|
endTime = null
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
),
|
),
|
||||||
).map {
|
).map {
|
||||||
|
|
|
||||||
|
|
@ -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.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
|
@ -32,51 +33,73 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
|
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemStateEventRow(
|
fun TimelineItemStateEventRow(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
|
showReadReceipts: Boolean,
|
||||||
|
isLastOutgoingMessage: Boolean,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
Box(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.wrapContentHeight(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
MessageStateEventContainer(
|
Box(
|
||||||
isHighlighted = isHighlighted,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.zIndex(-1f)
|
.fillMaxWidth()
|
||||||
.widthIn(max = 320.dp)
|
.padding(top = 8.dp, bottom = 2.dp)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
TimelineItemEventContentView(
|
MessageStateEventContainer(
|
||||||
content = event.content,
|
isHighlighted = isHighlighted,
|
||||||
isMine = event.isMine,
|
|
||||||
isEditable = event.isEditable,
|
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
extraPadding = noExtraPadding,
|
modifier = Modifier
|
||||||
eventSink = eventSink,
|
.zIndex(-1f)
|
||||||
modifier = Modifier.defaultTimelineContentPadding()
|
.widthIn(max = 320.dp)
|
||||||
)
|
) {
|
||||||
|
TimelineItemEventContentView(
|
||||||
|
content = event.content,
|
||||||
|
isMine = event.isMine,
|
||||||
|
isEditable = event.isEditable,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
extraPadding = noExtraPadding,
|
||||||
|
eventSink = eventSink,
|
||||||
|
modifier = Modifier.defaultTimelineContentPadding()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
TimelineItemReadReceiptView(
|
||||||
|
state = ReadReceiptViewState(
|
||||||
|
sendState = event.localSendState,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
|
receipts = event.readReceiptState.receipts,
|
||||||
|
),
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
onReadReceiptsClicked = { onReadReceiptsClick(event) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,11 +110,17 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = aTimelineItemStateEventContent(),
|
content = aTimelineItemStateEventContent(),
|
||||||
groupPosition = TimelineItemGroupPosition.None
|
groupPosition = TimelineItemGroupPosition.None,
|
||||||
|
readReceiptState = TimelineItemReadReceipts(
|
||||||
|
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
|
||||||
|
)
|
||||||
),
|
),
|
||||||
|
showReadReceipts = true,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
|
onReadReceiptsClick = {},
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.group
|
package io.element.android.features.messages.impl.timeline.components.group
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -76,9 +79,17 @@ fun GroupHeaderView(
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
)
|
)
|
||||||
|
val rotation: Float by animateFloatAsState(
|
||||||
|
targetValue = if (isExpanded) 90f else 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
delayMillis = 0,
|
||||||
|
durationMillis = 300,
|
||||||
|
),
|
||||||
|
label = "chevron"
|
||||||
|
)
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.rotate(if (isExpanded) 180f else 0f),
|
modifier = Modifier.rotate(rotation),
|
||||||
imageVector = CompoundIcons.ChevronDown,
|
imageVector = CompoundIcons.ChevronRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.secondary
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.reactionsummary
|
package io.element.android.features.messages.impl.timeline.components.reactionsummary
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
|
@ -38,10 +37,6 @@ class ReactionSummaryPresenter @Inject constructor(
|
||||||
) : Presenter<ReactionSummaryState> {
|
) : Presenter<ReactionSummaryState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ReactionSummaryState {
|
override fun present(): ReactionSummaryState {
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
room.updateMembers()
|
|
||||||
}
|
|
||||||
|
|
||||||
val membersState by room.membersStateFlow.collectAsState()
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
|
|
||||||
val target: MutableState<ReactionSummaryState.Summary?> = remember {
|
val target: MutableState<ReactionSummaryState.Summary?> = remember {
|
||||||
|
|
|
||||||
|
|
@ -33,23 +33,23 @@ class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewSta
|
||||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
|
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
|
||||||
aReadReceiptViewState(
|
aReadReceiptViewState(
|
||||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } },
|
receipts = List(1) { aReadReceiptData(it) },
|
||||||
),
|
),
|
||||||
aReadReceiptViewState(
|
aReadReceiptViewState(
|
||||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } },
|
receipts = List(2) { aReadReceiptData(it) },
|
||||||
),
|
),
|
||||||
aReadReceiptViewState(
|
aReadReceiptViewState(
|
||||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } },
|
receipts = List(3) { aReadReceiptData(it) },
|
||||||
),
|
),
|
||||||
aReadReceiptViewState(
|
aReadReceiptViewState(
|
||||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } },
|
receipts = List(4) { aReadReceiptData(it) },
|
||||||
),
|
),
|
||||||
aReadReceiptViewState(
|
aReadReceiptViewState(
|
||||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||||
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } },
|
receipts = List(5) { aReadReceiptData(it) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ package io.element.android.features.messages.impl.timeline.components.receipt.bo
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -38,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -83,34 +84,39 @@ internal fun ReadReceiptBottomSheet(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.ReadReceiptBottomSheetContent(
|
private fun ReadReceiptBottomSheetContent(
|
||||||
state: ReadReceiptBottomSheetState,
|
state: ReadReceiptBottomSheetState,
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
LazyColumn {
|
||||||
headlineContent = {
|
item {
|
||||||
Text(text = stringResource(id = CommonStrings.common_seen_by))
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = CommonStrings.common_seen_by))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
|
||||||
|
) {
|
||||||
|
val userId = UserId(it.avatarData.id)
|
||||||
|
MatrixUserRow(
|
||||||
|
modifier = Modifier.clickable { onUserDataClicked(userId) },
|
||||||
|
matrixUser = MatrixUser(
|
||||||
|
userId = userId,
|
||||||
|
displayName = it.avatarData.name,
|
||||||
|
avatarUrl = it.avatarData.url,
|
||||||
|
),
|
||||||
|
avatarSize = AvatarSize.ReadReceiptList,
|
||||||
|
trailingContent = {
|
||||||
|
Text(
|
||||||
|
text = it.formattedDate,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
val receipts = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
|
|
||||||
receipts.forEach {
|
|
||||||
val userId = UserId(it.avatarData.id)
|
|
||||||
MatrixUserRow(
|
|
||||||
modifier = Modifier.clickable { onUserDataClicked(userId) },
|
|
||||||
matrixUser = MatrixUser(
|
|
||||||
userId = userId,
|
|
||||||
displayName = it.avatarData.name,
|
|
||||||
avatarUrl = it.avatarData.url,
|
|
||||||
),
|
|
||||||
avatarSize = AvatarSize.ReadReceiptList,
|
|
||||||
trailingContent = {
|
|
||||||
Text(
|
|
||||||
text = it.formattedDate,
|
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
|
|
||||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -45,14 +44,15 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
||||||
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import java.time.Duration
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
class TimelineItemContentMessageFactory @Inject constructor(
|
class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
private val fileSizeFormatter: FileSizeFormatter,
|
private val fileSizeFormatter: FileSizeFormatter,
|
||||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
width = messageType.info?.width?.toInt(),
|
width = messageType.info?.width?.toInt(),
|
||||||
height = messageType.info?.height?.toInt(),
|
height = messageType.info?.height?.toInt(),
|
||||||
duration = messageType.info?.duration?.toMillis() ?: 0L,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
blurHash = messageType.info?.blurhash,
|
blurHash = messageType.info?.blurhash,
|
||||||
aspectRatio = aspectRatio,
|
aspectRatio = aspectRatio,
|
||||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ private fun MutableList<TimelineItem>.addGroup(
|
||||||
add(
|
add(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
id = groupId,
|
id = groupId,
|
||||||
events = groupOfItems.toImmutableList()
|
events = groupOfItems.toImmutableList(),
|
||||||
|
aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
/**
|
||||||
|
* Model if there is a new event in the timeline and if it is from me or from other.
|
||||||
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<TimelineItemGroupPosition> {
|
* This can be used to scroll to the bottom of the list when a new event is added.
|
||||||
override val values = sequenceOf(
|
*/
|
||||||
TimelineItemGroupPosition.First,
|
enum class NewEventState {
|
||||||
TimelineItemGroupPosition.Middle,
|
None, FromMe, FromOther
|
||||||
TimelineItemGroupPosition.Last,
|
|
||||||
TimelineItemGroupPosition.None,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +88,6 @@ sealed interface TimelineItem {
|
||||||
data class GroupedEvents(
|
data class GroupedEvents(
|
||||||
val id: String,
|
val id: String,
|
||||||
val events: ImmutableList<Event>,
|
val events: ImmutableList<Event>,
|
||||||
|
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
|
||||||
) : TimelineItem
|
) : TimelineItem
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import java.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemAudioContent(
|
data class TimelineItemAudioContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
|
@ -29,6 +28,10 @@ data class TimelineItemAudioContent(
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
|
|
||||||
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
|
val fileExtensionAndSize =
|
||||||
|
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
|
||||||
|
fileExtension,
|
||||||
|
formattedFileSize
|
||||||
|
)
|
||||||
override val type: String = "TimelineItemAudioContent"
|
override val type: String = "TimelineItemAudioContent"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import java.time.Duration
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
|
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
|
||||||
override val values: Sequence<TimelineItemAudioContent>
|
override val values: Sequence<TimelineItemAudioContent>
|
||||||
|
|
@ -35,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu
|
||||||
mimeType = MimeTypes.Pdf,
|
mimeType = MimeTypes.Pdf,
|
||||||
formattedFileSize = "100kB",
|
formattedFileSize = "100kB",
|
||||||
fileExtension = "mp3",
|
fileExtension = "mp3",
|
||||||
duration = Duration.ofMillis(100),
|
duration = 100.milliseconds,
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
|
||||||
|
|
||||||
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
|
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
|
||||||
|
|
||||||
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent(
|
fun aTimelineItemStateEventContent(
|
||||||
body = "A state event",
|
body: String = "A state event",
|
||||||
|
) = TimelineItemStateEventContent(
|
||||||
|
body = body,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||||
|
|
||||||
data class TimelineItemFileContent(
|
data class TimelineItemFileContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,11 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemVideoContent(
|
data class TimelineItemVideoContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
val duration: Long,
|
val duration: Duration,
|
||||||
val videoSource: MediaSource,
|
val videoSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val aspectRatio: Float?,
|
val aspectRatio: Float?,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
||||||
override val values: Sequence<TimelineItemVideoContent>
|
override val values: Sequence<TimelineItemVideoContent>
|
||||||
|
|
@ -35,7 +36,7 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = A_BLUR_HASH,
|
blurHash = A_BLUR_HASH,
|
||||||
aspectRatio = 0.5f,
|
aspectRatio = 0.5f,
|
||||||
duration = 100,
|
duration = 100.milliseconds,
|
||||||
videoSource = MediaSource(""),
|
videoSource = MediaSource(""),
|
||||||
height = 300,
|
height = 300,
|
||||||
width = 150,
|
width = 150,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import java.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemVoiceContent(
|
data class TimelineItemVoiceContent(
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import java.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
|
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
|
||||||
override val values: Sequence<TimelineItemVoiceContent>
|
override val values: Sequence<TimelineItemVoiceContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemVoiceContent(
|
aTimelineItemVoiceContent(
|
||||||
durationMs = 1,
|
duration = 1.milliseconds,
|
||||||
waveform = listOf(),
|
waveform = listOf(),
|
||||||
),
|
),
|
||||||
aTimelineItemVoiceContent(
|
aTimelineItemVoiceContent(
|
||||||
durationMs = 10_000,
|
duration = 10_000.milliseconds,
|
||||||
waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
|
waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
|
||||||
),
|
),
|
||||||
aTimelineItemVoiceContent(
|
aTimelineItemVoiceContent(
|
||||||
durationMs = 1_800_000, // 30 minutes
|
duration = 30.minutes,
|
||||||
waveform = List(1024) { it / 1024f },
|
waveform = List(1024) { it / 1024f },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -44,14 +46,14 @@ open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineI
|
||||||
fun aTimelineItemVoiceContent(
|
fun aTimelineItemVoiceContent(
|
||||||
eventId: String? = "\$anEventId",
|
eventId: String? = "\$anEventId",
|
||||||
body: String = "body doesn't really matter for a voice message",
|
body: String = "body doesn't really matter for a voice message",
|
||||||
durationMs: Long = 61_000,
|
duration: Duration = 61_000.milliseconds,
|
||||||
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
|
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
|
||||||
mimeType: String = MimeTypes.Ogg,
|
mimeType: String = MimeTypes.Ogg,
|
||||||
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
|
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
|
||||||
) = TimelineItemVoiceContent(
|
) = TimelineItemVoiceContent(
|
||||||
eventId = eventId?.let { EventId(it) },
|
eventId = eventId?.let { EventId(it) },
|
||||||
body = body,
|
body = body,
|
||||||
duration = Duration.ofMillis(durationMs),
|
duration = duration,
|
||||||
mediaSource = MediaSource(contentUri),
|
mediaSource = MediaSource(contentUri),
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
waveform = waveform.toPersistentList(),
|
waveform = waveform.toPersistentList(),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages.composer
|
package io.element.android.features.messages.impl.voicemessages.composer
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -41,7 +42,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MIME_TYPE = "audio/ogg"
|
const val MIME_TYPE = MimeTypes.Ogg
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mediaPath: String? = null
|
private var mediaPath: String? = null
|
||||||
|
|
@ -201,6 +202,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
progress = 0f,
|
progress = 0f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this player is currently playing.
|
* Whether this player is currently playing.
|
||||||
*/
|
*/
|
||||||
|
|
@ -212,7 +214,6 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
val isStopped get() = this.playState == PlayState.Stopped
|
val isStopped get() = this.playState == PlayState.Stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum class PlayState {
|
enum class PlayState {
|
||||||
/**
|
/**
|
||||||
* The player is stopped, i.e. it has just been initialised.
|
* The player is stopped, i.e. it has just been initialised.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
@ -196,7 +197,7 @@ class DefaultVoiceMessagePlayer(
|
||||||
mediaPlayer.setMedia(
|
mediaPlayer.setMedia(
|
||||||
uri = mediaFile.path,
|
uri = mediaFile.path,
|
||||||
mediaId = eventId.value,
|
mediaId = eventId.value,
|
||||||
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually.
|
mimeType = MimeTypes.Ogg, // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||||
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
|
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val duration by remember {
|
val duration by remember {
|
||||||
derivedStateOf { playerState.duration ?: content.duration.toMillis() }
|
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
|
||||||
}
|
}
|
||||||
val progress by remember {
|
val progress by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
<string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
|
<string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
|
||||||
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
|
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
|
||||||
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
|
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
|
||||||
|
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění."</string>
|
||||||
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
|
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
|
||||||
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
|
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
|
||||||
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string>
|
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string>
|
||||||
|
|
|
||||||
|
|
@ -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_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_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string>
|
||||||
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
|
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
|
||||||
|
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Votre serveur 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_mode_all_messages">"Tous les messages"</string>
|
||||||
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
|
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
|
||||||
<string name="screen_room_reactions_show_less">"Afficher moins"</string>
|
<string name="screen_room_reactions_show_less">"Afficher moins"</string>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
|
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
||||||
|
|
@ -80,6 +79,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
|
|
@ -92,12 +92,14 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.element.android.tests.testutils.waitForPredicate
|
import io.element.android.tests.testutils.waitForPredicate
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
class MessagesPresenterTest {
|
class MessagesPresenterTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
|
@ -125,6 +127,21 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
|
||||||
|
val room = FakeMatrixRoom().apply {
|
||||||
|
givenCanUserJoinCall(Result.success(false))
|
||||||
|
givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
||||||
|
}
|
||||||
|
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = consumeItemsUntilTimeout().last()
|
||||||
|
assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle toggling a reaction`() = runTest {
|
fun `present - handle toggling a reaction`() = runTest {
|
||||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||||
|
|
@ -264,7 +281,7 @@ class MessagesPresenterTest {
|
||||||
val mediaMessage = aMessageEvent(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemVideoContent(
|
content = TimelineItemVideoContent(
|
||||||
body = "video.mp4",
|
body = "video.mp4",
|
||||||
duration = 10L,
|
duration = 10.milliseconds,
|
||||||
videoSource = MediaSource(AN_AVATAR_URL),
|
videoSource = MediaSource(AN_AVATAR_URL),
|
||||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||||
mimeType = MimeTypes.Mp4,
|
mimeType = MimeTypes.Mp4,
|
||||||
|
|
@ -462,7 +479,7 @@ class MessagesPresenterTest {
|
||||||
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
room.givenRoomMembersState(
|
room.givenRoomMembersState(
|
||||||
MatrixRoomMembersState.Ready(
|
MatrixRoomMembersState.Ready(
|
||||||
listOf(
|
persistentListOf(
|
||||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
)
|
)
|
||||||
|
|
@ -489,7 +506,7 @@ class MessagesPresenterTest {
|
||||||
room.givenRoomMembersState(
|
room.givenRoomMembersState(
|
||||||
MatrixRoomMembersState.Error(
|
MatrixRoomMembersState.Error(
|
||||||
failure = Throwable(),
|
failure = Throwable(),
|
||||||
prevRoomMembers = listOf(
|
prevRoomMembers = persistentListOf(
|
||||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
)
|
)
|
||||||
|
|
@ -535,7 +552,7 @@ class MessagesPresenterTest {
|
||||||
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
room.givenRoomMembersState(
|
room.givenRoomMembersState(
|
||||||
MatrixRoomMembersState.Ready(
|
MatrixRoomMembersState.Ready(
|
||||||
listOf(
|
persistentListOf(
|
||||||
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
)
|
)
|
||||||
|
|
@ -655,6 +672,7 @@ class MessagesPresenterTest {
|
||||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
|
currentSessionIdHolder: CurrentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||||
|
|
@ -723,6 +741,7 @@ class MessagesPresenterTest {
|
||||||
featureFlagsService = FakeFeatureFlagService(),
|
featureFlagsService = FakeFeatureFlagService(),
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
|
currentSessionIdHolder = currentSessionIdHolder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
||||||
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
||||||
import io.element.android.features.messages.impl.fixtures.aLocalMedia
|
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.fixtures
|
package io.element.android.features.messages.impl.fixtures
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
|
||||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
|
||||||
|
|
||||||
fun aLocalMedia(
|
|
||||||
uri: Uri,
|
|
||||||
mediaInfo: MediaInfo = anImageInfo(),
|
|
||||||
) = LocalMedia(
|
|
||||||
uri = uri,
|
|
||||||
info = mediaInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
compressIfPossible = compressIfPossible,
|
compressIfPossible = compressIfPossible,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,7 +32,6 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
|
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||||
|
|
@ -40,6 +39,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
|
|
||||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||||
|
|
@ -67,6 +66,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
|
|
@ -78,11 +78,11 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.waitForPredicate
|
import io.element.android.tests.testutils.waitForPredicate
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.internal.immutableListOf
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import uniffi.wysiwyg_composer.MentionsState
|
import uniffi.wysiwyg_composer.MentionsState
|
||||||
|
|
@ -734,7 +734,7 @@ class MessageComposerPresenterTest {
|
||||||
isOneToOne = false,
|
isOneToOne = false,
|
||||||
).apply {
|
).apply {
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||||
immutableListOf(currentUser, invitedUser, bob, david),
|
persistentListOf(currentUser, invitedUser, bob, david),
|
||||||
))
|
))
|
||||||
givenCanTriggerRoomNotification(Result.success(true))
|
givenCanTriggerRoomNotification(Result.success(true))
|
||||||
}
|
}
|
||||||
|
|
@ -798,7 +798,7 @@ class MessageComposerPresenterTest {
|
||||||
isOneToOne = true,
|
isOneToOne = true,
|
||||||
).apply {
|
).apply {
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||||
immutableListOf(currentUser, invitedUser, bob, david),
|
persistentListOf(currentUser, invitedUser, bob, david),
|
||||||
))
|
))
|
||||||
givenCanTriggerRoomNotification(Result.success(true))
|
givenCanTriggerRoomNotification(Result.success(true))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||||
|
|
@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -48,9 +50,12 @@ import io.element.android.libraries.matrix.test.verification.FakeSessionVerifica
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||||
import io.element.android.tests.testutils.awaitWithLatch
|
import io.element.android.tests.testutils.awaitWithLatch
|
||||||
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.element.android.tests.testutils.waitForPredicate
|
import io.element.android.tests.testutils.waitForPredicate
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -187,28 +192,49 @@ class TimelinePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - covers hasNewItems scenarios`() = runTest {
|
fun `present - covers newEventState scenarios`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timeline = FakeMatrixTimeline()
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.hasNewItems).isFalse()
|
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||||
timeline.updateTimelineItems {
|
timeline.updateTimelineItems {
|
||||||
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
|
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
|
||||||
}
|
}
|
||||||
skipItems(1)
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
|
// Mimics sending a message, and assert newEventState is FromMe
|
||||||
timeline.updateTimelineItems { items ->
|
timeline.updateTimelineItems { items ->
|
||||||
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
|
val event = anEventTimelineItem(content = aMessageContent(), localSendState = LocalEventSendState.Sent(AN_EVENT_ID))
|
||||||
|
items + listOf(MatrixTimelineItem.Event(1, event))
|
||||||
}
|
}
|
||||||
skipItems(1)
|
consumeItemsUntilPredicate { it.timelineItems.size == 2 }
|
||||||
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
|
awaitLastSequentialItem().also { state ->
|
||||||
assertThat(awaitItem().hasNewItems).isTrue()
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||||
|
}
|
||||||
|
// Mimics receiving a message without clearing the previous FromMe
|
||||||
|
timeline.updateTimelineItems { items ->
|
||||||
|
val event = anEventTimelineItem(content = aMessageContent())
|
||||||
|
items + listOf(MatrixTimelineItem.Event(2, event))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 3 }
|
||||||
|
|
||||||
|
// Scroll to bottom to clear previous FromMe
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
assertThat(awaitItem().hasNewItems).isFalse()
|
awaitLastSequentialItem().also { state ->
|
||||||
|
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||||
|
}
|
||||||
|
// Mimics receiving a message and assert newEventState is FromOther
|
||||||
|
timeline.updateTimelineItems { items ->
|
||||||
|
val event = anEventTimelineItem(content = aMessageContent())
|
||||||
|
items + listOf(MatrixTimelineItem.Event(3, event))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||||
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +247,7 @@ class TimelinePresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.hasNewItems).isFalse()
|
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||||
val now = Date().time
|
val now = Date().time
|
||||||
val minuteInMillis = 60 * 1000
|
val minuteInMillis = 60 * 1000
|
||||||
|
|
@ -229,18 +255,18 @@ class TimelinePresenterTest {
|
||||||
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
|
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
|
||||||
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
|
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
|
||||||
}
|
}
|
||||||
val oneReaction = listOf(
|
val oneReaction = persistentListOf(
|
||||||
EventReaction(
|
EventReaction(
|
||||||
key = "❤️",
|
key = "❤️",
|
||||||
senders = listOf(alice, charlie)
|
senders = persistentListOf(alice, charlie)
|
||||||
),
|
),
|
||||||
EventReaction(
|
EventReaction(
|
||||||
key = "👍",
|
key = "👍",
|
||||||
senders = listOf(alice, bob)
|
senders = persistentListOf(alice, bob)
|
||||||
),
|
),
|
||||||
EventReaction(
|
EventReaction(
|
||||||
key = "🐶",
|
key = "🐶",
|
||||||
senders = listOf(charlie)
|
senders = persistentListOf(charlie)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
timeline.updateTimelineItems {
|
timeline.updateTimelineItems {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -42,7 +43,7 @@ class ReactionSummaryPresenterTests {
|
||||||
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
|
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
|
||||||
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
|
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
|
||||||
private val room = FakeMatrixRoom().apply {
|
private val room = FakeMatrixRoom().apply {
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
|
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
|
||||||
}
|
}
|
||||||
private val presenter = ReactionSummaryPresenter(room)
|
private val presenter = ReactionSummaryPresenter(room)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
|
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -55,11 +54,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||||
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.time.Duration
|
import kotlin.time.Duration
|
||||||
import java.time.Duration.ofMinutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
class TimelineItemContentMessageFactoryTest {
|
class TimelineItemContentMessageFactoryTest {
|
||||||
|
|
||||||
|
|
@ -140,14 +141,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
duration = 0,
|
duration = Duration.ZERO,
|
||||||
videoSource = MediaSource(url = "url", json = null),
|
videoSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
aspectRatio = null,
|
aspectRatio = null,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
height = null,
|
height = null,
|
||||||
width = null,
|
width = null,
|
||||||
mimeType = "application/octet-stream",
|
mimeType = MimeTypes.OctetStream,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
fileExtension = "",
|
fileExtension = "",
|
||||||
)
|
)
|
||||||
|
|
@ -163,7 +164,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body.mp4",
|
body = "body.mp4",
|
||||||
source = MediaSource("url"),
|
source = MediaSource("url"),
|
||||||
info = VideoInfo(
|
info = VideoInfo(
|
||||||
duration = ofMinutes(1),
|
duration = 1.minutes,
|
||||||
height = 100,
|
height = 100,
|
||||||
width = 300,
|
width = 300,
|
||||||
mimetype = MimeTypes.Mp4,
|
mimetype = MimeTypes.Mp4,
|
||||||
|
|
@ -184,7 +185,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
body = "body.mp4",
|
body = "body.mp4",
|
||||||
duration = 60_000,
|
duration = 1.minutes,
|
||||||
videoSource = MediaSource(url = "url", json = null),
|
videoSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
aspectRatio = 3f,
|
aspectRatio = 3f,
|
||||||
|
|
@ -210,7 +211,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body",
|
body = "body",
|
||||||
duration = Duration.ZERO,
|
duration = Duration.ZERO,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
mimeType = "application/octet-stream",
|
mimeType = MimeTypes.OctetStream,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
fileExtension = "",
|
fileExtension = "",
|
||||||
)
|
)
|
||||||
|
|
@ -226,7 +227,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body.mp3",
|
body = "body.mp3",
|
||||||
source = MediaSource("url"),
|
source = MediaSource("url"),
|
||||||
info = AudioInfo(
|
info = AudioInfo(
|
||||||
duration = ofMinutes(1),
|
duration = 1.minutes,
|
||||||
size = 123L,
|
size = 123L,
|
||||||
mimetype = MimeTypes.Mp3,
|
mimetype = MimeTypes.Mp3,
|
||||||
)
|
)
|
||||||
|
|
@ -237,7 +238,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
body = "body.mp3",
|
body = "body.mp3",
|
||||||
duration = ofMinutes(1),
|
duration = 1.minutes,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
mimeType = MimeTypes.Mp3,
|
mimeType = MimeTypes.Mp3,
|
||||||
formattedFileSize = "123 Bytes",
|
formattedFileSize = "123 Bytes",
|
||||||
|
|
@ -259,7 +260,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body",
|
body = "body",
|
||||||
duration = Duration.ZERO,
|
duration = Duration.ZERO,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
mimeType = "application/octet-stream",
|
mimeType = MimeTypes.OctetStream,
|
||||||
waveform = emptyList<Float>().toImmutableList()
|
waveform = emptyList<Float>().toImmutableList()
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
|
|
@ -274,12 +275,13 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body.ogg",
|
body = "body.ogg",
|
||||||
source = MediaSource("url"),
|
source = MediaSource("url"),
|
||||||
info = AudioInfo(
|
info = AudioInfo(
|
||||||
duration = ofMinutes(1),
|
duration = 1.minutes,
|
||||||
size = 123L,
|
size = 123L,
|
||||||
mimetype = MimeTypes.Ogg,
|
mimetype = MimeTypes.Ogg,
|
||||||
),
|
),
|
||||||
details = AudioDetails(
|
details = AudioDetails(
|
||||||
duration = ofMinutes(1), waveform = listOf(1f, 2f)
|
duration = 1.minutes,
|
||||||
|
waveform = persistentListOf(1f, 2f),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -289,10 +291,10 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemVoiceContent(
|
val expected = TimelineItemVoiceContent(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
body = "body.ogg",
|
body = "body.ogg",
|
||||||
duration = ofMinutes(1),
|
duration = 1.minutes,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
mimeType = MimeTypes.Ogg,
|
mimeType = MimeTypes.Ogg,
|
||||||
waveform = listOf(1f, 2f).toImmutableList()
|
waveform = persistentListOf(1f, 2f)
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +317,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body",
|
body = "body",
|
||||||
duration = Duration.ZERO,
|
duration = Duration.ZERO,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
mimeType = "application/octet-stream",
|
mimeType = MimeTypes.OctetStream,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
fileExtension = ""
|
fileExtension = ""
|
||||||
)
|
)
|
||||||
|
|
@ -336,7 +338,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
fileExtension = "",
|
fileExtension = "",
|
||||||
mimeType = "application/octet-stream",
|
mimeType = MimeTypes.OctetStream,
|
||||||
blurhash = null,
|
blurhash = null,
|
||||||
width = null,
|
width = null,
|
||||||
height = null,
|
height = null,
|
||||||
|
|
@ -401,7 +403,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
fileExtension = "",
|
fileExtension = "",
|
||||||
mimeType = "application/octet-stream"
|
mimeType = MimeTypes.OctetStream
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ import io.element.android.libraries.matrix.test.A_USER_ID_7
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_8
|
import io.element.android.libraries.matrix.test.A_USER_ID_8
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_9
|
import io.element.android.libraries.matrix.test.A_USER_ID_9
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -55,7 +60,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
|
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
|
||||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(votes = votes), eventId = null)
|
factory.create(aPollContent(votes = votes), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -87,7 +92,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
||||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -106,7 +111,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
||||||
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -136,7 +141,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
|
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
|
||||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
|
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -172,7 +177,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
||||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
|
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -192,7 +197,7 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
||||||
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
|
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
|
||||||
Truth.assertThat(
|
Truth.assertThat(
|
||||||
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
|
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
|
||||||
)
|
)
|
||||||
|
|
@ -221,13 +226,13 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
|
|
||||||
private fun aPollContent(
|
private fun aPollContent(
|
||||||
pollKind: PollKind = PollKind.Disclosed,
|
pollKind: PollKind = PollKind.Disclosed,
|
||||||
votes: Map<String, List<UserId>> = emptyMap(),
|
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
|
||||||
endTime: ULong? = null,
|
endTime: ULong? = null,
|
||||||
): PollContent = PollContent(
|
): PollContent = PollContent(
|
||||||
question = A_POLL_QUESTION,
|
question = A_POLL_QUESTION,
|
||||||
kind = pollKind,
|
kind = pollKind,
|
||||||
maxSelections = 1UL,
|
maxSelections = 1UL,
|
||||||
answers = listOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
|
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
|
||||||
votes = votes,
|
votes = votes,
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -277,17 +282,17 @@ internal class TimelineItemContentPollFactoryTest {
|
||||||
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
|
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
|
||||||
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
|
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
|
||||||
|
|
||||||
private val MY_USER_WINNING_VOTES = mapOf(
|
private val MY_USER_WINNING_VOTES = persistentMapOf(
|
||||||
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
|
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
|
||||||
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
|
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
|
||||||
A_POLL_ANSWER_3 to emptyList(),
|
A_POLL_ANSWER_3 to persistentListOf(),
|
||||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_10),
|
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
|
||||||
)
|
)
|
||||||
private val OTHER_WINNING_VOTES = mapOf(
|
private val OTHER_WINNING_VOTES = persistentMapOf(
|
||||||
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
|
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
|
||||||
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6),
|
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
|
||||||
A_POLL_ANSWER_3 to emptyList(),
|
A_POLL_ANSWER_3 to persistentListOf(),
|
||||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
|
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,12 @@ class TimelineItemGrouperTest {
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
computeGroupIdWith(aGroupableItem),
|
id = computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem.copy("0"),
|
aGroupableItem.copy("0"),
|
||||||
aGroupableItem.copy(id = "1"),
|
aGroupableItem.copy(id = "1"),
|
||||||
).toImmutableList()
|
).toImmutableList(),
|
||||||
|
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -132,20 +133,22 @@ class TimelineItemGrouperTest {
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
computeGroupIdWith(aGroupableItem),
|
id = computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
).toImmutableList()
|
).toImmutableList(),
|
||||||
|
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||||
),
|
),
|
||||||
aNonGroupableItem,
|
aNonGroupableItem,
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
computeGroupIdWith(aGroupableItem),
|
id = computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
).toImmutableList()
|
).toImmutableList(),
|
||||||
|
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
|
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||||
|
|
@ -143,7 +144,7 @@ private fun createDefaultVoiceMessageMediaRepo(
|
||||||
url = mxcUri,
|
url = mxcUri,
|
||||||
json = null
|
json = null
|
||||||
),
|
),
|
||||||
mimeType = "audio/ogg",
|
mimeType = MimeTypes.Ogg,
|
||||||
body = "someBody.ogg"
|
body = "someBody.ogg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
import app.cash.turbine.TurbineTestContext
|
import app.cash.turbine.TurbineTestContext
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -287,7 +288,7 @@ private fun createDefaultVoiceMessagePlayer(
|
||||||
url = MXC_URI,
|
url = MXC_URI,
|
||||||
json = null
|
json = null
|
||||||
),
|
),
|
||||||
mimeType = "audio/ogg",
|
mimeType = MimeTypes.Ogg,
|
||||||
body = "someBody.ogg"
|
body = "someBody.ogg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
|
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -37,7 +39,7 @@ class RedactedVoiceMessageManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `redacted event - no playing related media`() = runTest {
|
fun `redacted event - no playing related media`() = runTest {
|
||||||
val mediaPlayer = FakeMediaPlayer().apply {
|
val mediaPlayer = FakeMediaPlayer().apply {
|
||||||
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg")
|
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
||||||
|
|
@ -54,7 +56,7 @@ class RedactedVoiceMessageManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `redacted event - playing related media is paused`() = runTest {
|
fun `redacted event - playing related media is paused`() = runTest {
|
||||||
val mediaPlayer = FakeMediaPlayer().apply {
|
val mediaPlayer = FakeMediaPlayer().apply {
|
||||||
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg")
|
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
|
||||||
|
|
@ -87,8 +89,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||||
isOwn = false,
|
isOwn = false,
|
||||||
isRemote = false,
|
isRemote = false,
|
||||||
localSendState = null,
|
localSendState = null,
|
||||||
reactions = listOf(),
|
reactions = persistentListOf(),
|
||||||
receipts = listOf(),
|
receipts = persistentListOf(),
|
||||||
sender = A_USER_ID,
|
sender = A_USER_ID,
|
||||||
senderProfile = ProfileTimelineDetails.Unavailable,
|
senderProfile = ProfileTimelineDetails.Unavailable,
|
||||||
timestamp = 9442,
|
timestamp = 9442,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class VoiceMessagePresenterTest {
|
class VoiceMessagePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -49,7 +50,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `pressing play downloads and plays`() = runTest {
|
fun `pressing play downloads and plays`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -87,7 +88,7 @@ class VoiceMessagePresenterTest {
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -123,7 +124,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `pressing pause while playing pauses`() = runTest {
|
fun `pressing pause while playing pauses`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -172,7 +173,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `seeking before play`() = runTest {
|
fun `seeking before play`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(durationMs = 10_000),
|
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -196,7 +197,7 @@ class VoiceMessagePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `seeking after play`() = runTest {
|
fun `seeking after play`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
content = aTimelineItemVoiceContent(durationMs = 10_000),
|
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
sealed interface CreatePollEvents {
|
||||||
data object Save : CreatePollEvents
|
data object Save : CreatePollEvents
|
||||||
|
data class Delete(val confirmed: Boolean) : CreatePollEvents
|
||||||
data class SetQuestion(val question: String) : CreatePollEvents
|
data class SetQuestion(val question: String) : CreatePollEvents
|
||||||
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
|
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
|
||||||
data object AddAnswer : CreatePollEvents
|
data object AddAnswer : CreatePollEvents
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,11 @@ package io.element.android.features.poll.impl.create
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -34,21 +32,19 @@ import im.vector.app.features.analytics.plan.Composer
|
||||||
import im.vector.app.features.analytics.plan.PollCreation
|
import im.vector.app.features.analytics.plan.PollCreation
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.features.poll.api.create.CreatePollMode
|
import io.element.android.features.poll.api.create.CreatePollMode
|
||||||
|
import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS
|
||||||
import io.element.android.features.poll.impl.data.PollRepository
|
import io.element.android.features.poll.impl.data.PollRepository
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||||
|
import io.element.android.libraries.matrix.api.poll.isDisclosed
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val MIN_ANSWERS = 2
|
|
||||||
private const val MAX_ANSWERS = 20
|
|
||||||
private const val MAX_ANSWER_LENGTH = 240
|
|
||||||
private const val MAX_SELECTIONS = 1
|
|
||||||
|
|
||||||
class CreatePollPresenter @AssistedInject constructor(
|
class CreatePollPresenter @AssistedInject constructor(
|
||||||
private val repository: PollRepository,
|
private val repository: PollRepository,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
|
|
@ -64,17 +60,31 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): CreatePollState {
|
override fun present(): CreatePollState {
|
||||||
var question: String by rememberSaveable { mutableStateOf("") }
|
// The initial state of the form. In edit mode this will be populated with the poll being edited.
|
||||||
var answers: List<String> by rememberSaveable { mutableStateOf(listOf("", "")) }
|
var initialPoll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
|
||||||
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
|
mutableStateOf(PollFormState.Empty)
|
||||||
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
}
|
||||||
|
// The current state of the form.
|
||||||
|
var poll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
|
||||||
|
mutableStateOf(initialPoll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the form has been changed from the initial state
|
||||||
|
val isDirty: Boolean by remember { derivedStateOf { poll != initialPoll } }
|
||||||
|
|
||||||
|
var showBackConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showDeleteConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (mode is CreatePollMode.EditPoll) {
|
if (mode is CreatePollMode.EditPoll) {
|
||||||
repository.getPoll(mode.eventId).onSuccess {
|
repository.getPoll(mode.eventId).onSuccess {
|
||||||
question = it.question
|
val loadedPoll = PollFormState(
|
||||||
answers = it.answers.map(PollAnswer::text)
|
question = it.question,
|
||||||
pollKind = it.kind
|
answers = it.answers.map(PollAnswer::text).toPersistentList(),
|
||||||
|
isDisclosed = it.kind.isDisclosed,
|
||||||
|
)
|
||||||
|
initialPoll = loadedPoll
|
||||||
|
poll = loadedPoll
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
analyticsService.trackGetPollFailed(it)
|
analyticsService.trackGetPollFailed(it)
|
||||||
navigateUp()
|
navigateUp()
|
||||||
|
|
@ -82,9 +92,9 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val canSave: Boolean by remember { derivedStateOf { canSave(question, answers) } }
|
val canSave: Boolean by remember { derivedStateOf { poll.isValid } }
|
||||||
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
|
val canAddAnswer: Boolean by remember { derivedStateOf { poll.canAddAnswer } }
|
||||||
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
|
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { poll.toUiAnswers() } }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -97,14 +107,14 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||||
is CreatePollMode.EditPoll -> mode.eventId
|
is CreatePollMode.EditPoll -> mode.eventId
|
||||||
is CreatePollMode.NewPoll -> null
|
is CreatePollMode.NewPoll -> null
|
||||||
},
|
},
|
||||||
question = question,
|
question = poll.question,
|
||||||
answers = answers,
|
answers = poll.answers,
|
||||||
pollKind = pollKind,
|
pollKind = poll.pollKind,
|
||||||
maxSelections = MAX_SELECTIONS,
|
maxSelections = MAX_SELECTIONS,
|
||||||
).onSuccess {
|
).onSuccess {
|
||||||
analyticsService.capturePollSaved(
|
analyticsService.capturePollSaved(
|
||||||
isUndisclosed = pollKind == PollKind.Undisclosed,
|
isUndisclosed = poll.pollKind == PollKind.Undisclosed,
|
||||||
numberOfAnswers = answers.size,
|
numberOfAnswers = poll.answers.size,
|
||||||
)
|
)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
analyticsService.trackSavePollFailed(it, mode)
|
analyticsService.trackSavePollFailed(it, mode)
|
||||||
|
|
@ -114,35 +124,52 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||||
Timber.d("Cannot create poll")
|
Timber.d("Cannot create poll")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CreatePollEvents.AddAnswer -> {
|
is CreatePollEvents.Delete -> {
|
||||||
answers = answers + ""
|
if (mode !is CreatePollMode.EditPoll) {
|
||||||
}
|
return
|
||||||
is CreatePollEvents.RemoveAnswer -> {
|
}
|
||||||
answers = answers.filterIndexed { index, _ -> index != event.index }
|
|
||||||
}
|
if (!event.confirmed) {
|
||||||
is CreatePollEvents.SetAnswer -> {
|
showDeleteConfirmation = true
|
||||||
answers = answers.toMutableList().apply {
|
return
|
||||||
this[event.index] = event.text.take(MAX_ANSWER_LENGTH)
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
showDeleteConfirmation = false
|
||||||
|
repository.deletePoll(mode.eventId)
|
||||||
|
navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is CreatePollEvents.AddAnswer -> {
|
||||||
|
poll = poll.withNewAnswer()
|
||||||
|
}
|
||||||
|
is CreatePollEvents.RemoveAnswer -> {
|
||||||
|
poll= poll.withAnswerRemoved(event.index)
|
||||||
|
}
|
||||||
|
is CreatePollEvents.SetAnswer -> {
|
||||||
|
poll = poll.withAnswerChanged(event.index, event.text)
|
||||||
|
}
|
||||||
is CreatePollEvents.SetPollKind -> {
|
is CreatePollEvents.SetPollKind -> {
|
||||||
pollKind = event.pollKind
|
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
|
||||||
}
|
}
|
||||||
is CreatePollEvents.SetQuestion -> {
|
is CreatePollEvents.SetQuestion -> {
|
||||||
question = event.question
|
poll = poll.copy(question = event.question)
|
||||||
}
|
}
|
||||||
is CreatePollEvents.NavBack -> {
|
is CreatePollEvents.NavBack -> {
|
||||||
navigateUp()
|
navigateUp()
|
||||||
}
|
}
|
||||||
CreatePollEvents.ConfirmNavBack -> {
|
CreatePollEvents.ConfirmNavBack -> {
|
||||||
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() }
|
val shouldConfirm = isDirty
|
||||||
if (shouldConfirm) {
|
if (shouldConfirm) {
|
||||||
showConfirmation = true
|
showBackConfirmation = true
|
||||||
} else {
|
} else {
|
||||||
navigateUp()
|
navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CreatePollEvents.HideConfirmation -> showConfirmation = false
|
is CreatePollEvents.HideConfirmation -> {
|
||||||
|
showBackConfirmation = false
|
||||||
|
showDeleteConfirmation = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,10 +180,11 @@ class CreatePollPresenter @AssistedInject constructor(
|
||||||
},
|
},
|
||||||
canSave = canSave,
|
canSave = canSave,
|
||||||
canAddAnswer = canAddAnswer,
|
canAddAnswer = canAddAnswer,
|
||||||
question = question,
|
question = poll.question,
|
||||||
answers = immutableAnswers,
|
answers = immutableAnswers,
|
||||||
pollKind = pollKind,
|
pollKind = poll.pollKind,
|
||||||
showConfirmation = showConfirmation,
|
showBackConfirmation = showBackConfirmation,
|
||||||
|
showDeleteConfirmation = showDeleteConfirmation,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -207,35 +235,12 @@ private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreateP
|
||||||
trackError(exception)
|
trackError(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canSave(
|
fun PollFormState.toUiAnswers(): ImmutableList<Answer> {
|
||||||
question: String,
|
return answers.map { answer ->
|
||||||
answers: List<String>
|
|
||||||
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
|
|
||||||
|
|
||||||
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
|
|
||||||
|
|
||||||
fun List<String>.toAnswers(): ImmutableList<Answer> {
|
|
||||||
return map { answer ->
|
|
||||||
Answer(
|
Answer(
|
||||||
text = answer,
|
text = answer,
|
||||||
canDelete = this.size > MIN_ANSWERS,
|
canDelete = canDeleteAnswer,
|
||||||
)
|
)
|
||||||
}.toImmutableList()
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
|
|
||||||
save = {
|
|
||||||
when (it.value) {
|
|
||||||
PollKind.Disclosed -> false
|
|
||||||
PollKind.Undisclosed -> true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restore = {
|
|
||||||
mutableStateOf(
|
|
||||||
when (it) {
|
|
||||||
true -> PollKind.Undisclosed
|
|
||||||
else -> PollKind.Disclosed
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,16 @@ data class CreatePollState(
|
||||||
val question: String,
|
val question: String,
|
||||||
val answers: ImmutableList<Answer>,
|
val answers: ImmutableList<Answer>,
|
||||||
val pollKind: PollKind,
|
val pollKind: PollKind,
|
||||||
val showConfirmation: Boolean,
|
val showBackConfirmation: Boolean,
|
||||||
|
val showDeleteConfirmation: Boolean,
|
||||||
val eventSink: (CreatePollEvents) -> Unit,
|
val eventSink: (CreatePollEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
enum class Mode {
|
enum class Mode {
|
||||||
New,
|
New,
|
||||||
Edit,
|
Edit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val canDelete: Boolean = mode == Mode.Edit
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Answer(
|
data class Answer(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("", false)
|
Answer("", false)
|
||||||
),
|
),
|
||||||
pollKind = PollKind.Disclosed,
|
pollKind = PollKind.Disclosed,
|
||||||
showConfirmation = false,
|
showBackConfirmation = false,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
mode = CreatePollState.Mode.New,
|
mode = CreatePollState.Mode.New,
|
||||||
|
|
@ -45,7 +46,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||||
),
|
),
|
||||||
showConfirmation = false,
|
showBackConfirmation = false,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
pollKind = PollKind.Undisclosed,
|
pollKind = PollKind.Undisclosed,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
|
|
@ -57,7 +59,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||||
),
|
),
|
||||||
showConfirmation = true,
|
showBackConfirmation = true,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
pollKind = PollKind.Undisclosed,
|
pollKind = PollKind.Undisclosed,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
|
|
@ -71,7 +74,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
||||||
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
|
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
|
||||||
),
|
),
|
||||||
showConfirmation = false,
|
showBackConfirmation = false,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
pollKind = PollKind.Undisclosed,
|
pollKind = PollKind.Undisclosed,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
|
|
@ -101,7 +105,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("19", true),
|
Answer("19", true),
|
||||||
Answer("20", true),
|
Answer("20", true),
|
||||||
),
|
),
|
||||||
showConfirmation = false,
|
showBackConfirmation = false,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
pollKind = PollKind.Undisclosed,
|
pollKind = PollKind.Undisclosed,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
|
|
@ -124,7 +129,8 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
showConfirmation = false,
|
showBackConfirmation = false,
|
||||||
|
showDeleteConfirmation = false,
|
||||||
pollKind = PollKind.Undisclosed,
|
pollKind = PollKind.Undisclosed,
|
||||||
),
|
),
|
||||||
aCreatePollState(
|
aCreatePollState(
|
||||||
|
|
@ -137,7 +143,21 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||||
Answer("", false)
|
Answer("", false)
|
||||||
),
|
),
|
||||||
pollKind = PollKind.Disclosed,
|
pollKind = PollKind.Disclosed,
|
||||||
showConfirmation = false,
|
showDeleteConfirmation = false,
|
||||||
|
showBackConfirmation = false,
|
||||||
|
),
|
||||||
|
aCreatePollState(
|
||||||
|
mode = CreatePollState.Mode.Edit,
|
||||||
|
canCreate = false,
|
||||||
|
canAddAnswer = true,
|
||||||
|
question = "",
|
||||||
|
answers = persistentListOf(
|
||||||
|
Answer("", false),
|
||||||
|
Answer("", false)
|
||||||
|
),
|
||||||
|
pollKind = PollKind.Disclosed,
|
||||||
|
showDeleteConfirmation = true,
|
||||||
|
showBackConfirmation = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +168,8 @@ private fun aCreatePollState(
|
||||||
canAddAnswer: Boolean,
|
canAddAnswer: Boolean,
|
||||||
question: String,
|
question: String,
|
||||||
answers: PersistentList<Answer>,
|
answers: PersistentList<Answer>,
|
||||||
showConfirmation: Boolean,
|
showBackConfirmation: Boolean,
|
||||||
|
showDeleteConfirmation: Boolean,
|
||||||
pollKind: PollKind
|
pollKind: PollKind
|
||||||
): CreatePollState {
|
): CreatePollState {
|
||||||
return CreatePollState(
|
return CreatePollState(
|
||||||
|
|
@ -157,7 +178,8 @@ private fun aCreatePollState(
|
||||||
canAddAnswer = canAddAnswer,
|
canAddAnswer = canAddAnswer,
|
||||||
question = question,
|
question = question,
|
||||||
answers = answers,
|
answers = answers,
|
||||||
showConfirmation = showConfirmation,
|
showBackConfirmation = showBackConfirmation,
|
||||||
|
showDeleteConfirmation = showDeleteConfirmation,
|
||||||
pollKind = pollKind,
|
pollKind = pollKind,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,21 @@ fun CreatePollView(
|
||||||
|
|
||||||
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
|
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
|
||||||
BackHandler(onBack = navBack)
|
BackHandler(onBack = navBack)
|
||||||
if (state.showConfirmation) ConfirmationDialog(
|
if (state.showBackConfirmation) {
|
||||||
content = stringResource(id = R.string.screen_create_poll_discard_confirmation),
|
ConfirmationDialog(
|
||||||
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
|
content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android),
|
||||||
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
|
||||||
)
|
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.showDeleteConfirmation) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title),
|
||||||
|
content = stringResource(id = R.string.screen_edit_poll_delete_confirmation),
|
||||||
|
onSubmitClicked = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) },
|
||||||
|
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
||||||
|
)
|
||||||
|
}
|
||||||
val questionFocusRequester = remember { FocusRequester() }
|
val questionFocusRequester = remember { FocusRequester() }
|
||||||
val answerFocusRequester = remember { FocusRequester() }
|
val answerFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -191,6 +201,13 @@ fun CreatePollView(
|
||||||
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
|
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (state.canDelete) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
|
||||||
|
style = ListItemStyle.Destructive,
|
||||||
|
onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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_desc">"Zobrazit výsledky až po skončení hlasování"</string>
|
||||||
<string name="screen_create_poll_anonymous_headline">"Anonymní hlasování"</string>
|
<string name="screen_create_poll_anonymous_headline">"Anonymní hlasování"</string>
|
||||||
<string name="screen_create_poll_answer_hint">"Volba %1$d"</string>
|
<string name="screen_create_poll_answer_hint">"Volba %1$d"</string>
|
||||||
<string name="screen_create_poll_discard_confirmation">"Opravdu chcete zrušit toto hlasování?"</string>
|
<string name="screen_create_poll_cancel_confirmation_content_android">"Vaše změny nebyly uloženy. Opravdu se chcete vrátit?"</string>
|
||||||
<string name="screen_create_poll_discard_confirmation_title">"Zrušit hlasování"</string>
|
|
||||||
<string name="screen_create_poll_question_desc">"Otázka nebo téma"</string>
|
<string name="screen_create_poll_question_desc">"Otázka nebo téma"</string>
|
||||||
<string name="screen_create_poll_question_hint">"Čeho se hlasování týká?"</string>
|
<string name="screen_create_poll_question_hint">"Čeho se hlasování týká?"</string>
|
||||||
<string name="screen_create_poll_title">"Vytvořit hlasování"</string>
|
<string name="screen_create_poll_title">"Vytvořit hlasování"</string>
|
||||||
|
<string name="screen_edit_poll_delete_confirmation">"Opravdu chcete odstranit toto hlasování?"</string>
|
||||||
|
<string name="screen_edit_poll_delete_confirmation_title">"Odstranit hlasování"</string>
|
||||||
|
<string name="screen_edit_poll_title">"Upravit hlasování"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue