Merge remote-tracking branch 'origin/develop' into feature/cjs/view-location-in-timeline

This commit is contained in:
Chris Smith 2023-06-30 09:30:31 +01:00
commit 005b22391f
454 changed files with 2400 additions and 1234 deletions

View file

@ -34,7 +34,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v2.5.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK

View file

@ -56,7 +56,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v2.5.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis

View file

@ -39,7 +39,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v2.5.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite

View file

@ -24,7 +24,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v2.5.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots

View file

@ -33,7 +33,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v2.5.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -4,7 +4,9 @@
<w>backstack</w>
<w>homeserver</w>
<w>kover</w>
<w>measurables</w>
<w>onboarding</w>
<w>placeables</w>
<w>showkase</w>
<w>textfields</w>
</words>

View file

@ -92,13 +92,13 @@ android {
buildTypes {
named("debug") {
resValue("string", "app_name", "ElementX dbg")
resValue("string", "app_name", "Element X dbg")
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug")
}
named("release") {
resValue("string", "app_name", "ElementX")
resValue("string", "app_name", "Element X")
signingConfig = signingConfigs.getByName("debug")
postprocessing {
@ -115,7 +115,7 @@ android {
initWith(release)
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "ElementX nightly")
resValue("string", "app_name", "Element X nightly")
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")
@ -229,4 +229,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
ksp(libs.showkase.processor)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

@ -0,0 +1,49 @@
/*
* 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.x.icon
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.x.R
@Preview
@Composable
fun IconPreview(
modifier: Modifier = Modifier,
) {
Box {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
}
}
@Preview
@Composable
fun RoundIconPreview(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.clip(shape = CircleShape)) {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
}
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher_round"
android:insetTop="80dp"
android:insetRight="80dp"
android:insetBottom="80dp"
android:insetLeft="80dp" />

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#00000000" />
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<!-- Waiting for design monochrome android:drawable="@mipmap/ic_launcher_monochrome" /-->
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<!-- Waiting for design monochrome android:drawable="@mipmap/ic_launcher_monochrome" /-->
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

@ -17,8 +17,8 @@
<resources>
<style name="Theme.ElementX.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="windowSplashScreenBackground">@color/splashscreen_bg_dark</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
@ -16,11 +15,8 @@
-->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
<color name="splashscreen_bg_dark">#FF101317</color>
<!-- Must be equal to LightDesignTokens.colorThemeBg -->
<color name="splashscreen_bg_light">#FFFFFFFF</color>
</resources>

View file

@ -16,9 +16,9 @@
-->
<resources>
<style name="Theme.ElementX.Splash" parent="Theme.SplashScreen.IconBackground">
<item name="windowSplashScreenBackground">@color/white</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<style name="Theme.ElementX.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_bg_light</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style>
<style name="Theme.ElementX" parent="Theme.Material3.Light" />

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
@ -88,14 +89,12 @@ class RoomFlowNode @AssistedInject constructor(
lifecycle.subscribe(
onCreate = {
Timber.v("OnCreate")
inputs.room.open()
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.room) }
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers()
},
onDestroy = {
Timber.v("OnDestroy")
inputs.room.close()
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) }
appNavigationStateService.onLeavingRoom(id)
}
@ -161,6 +160,15 @@ class RoomFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
// Rely on the View Lifecycle instead of the Node Lifecycle,
// because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed.
DisposableEffect(Unit) {
inputs.room.open()
onDispose {
inputs.room.close()
}
}
Children(
navModel = backstack,
modifier = modifier,

View file

@ -247,7 +247,7 @@ koverMerged {
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.api.MapState"
excludes += "io.element.android.features.location.impl.map.MapState"
}
bound {
minValue = 90

View file

@ -26,7 +26,7 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
}
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "ElementX",
applicationName = "Element X",
isEnabled = false,
eventSink = {}
)

View file

@ -17,8 +17,6 @@
package io.element.android.features.analytics.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject
open class AnalyticsOptInStateProvider @Inject constructor(
@ -30,6 +28,6 @@ open class AnalyticsOptInStateProvider @Inject constructor(
}
fun aAnalyticsOptInState() = AnalyticsOptInState(
applicationName = "ElementX",
applicationName = "Element X",
eventSink = {}
)

View file

@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@ -50,14 +49,12 @@ import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import io.element.android.libraries.ui.strings.CommonStrings
private val minHeight = 72.dp
@ -161,8 +158,7 @@ internal fun DefaultInviteSummaryRow(
}
}
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
UnreadIndicatorAtom(color = unreadIndicatorColor)
UnreadIndicatorAtom(isVisible = invite.isNew)
}
}

View file

@ -16,8 +16,6 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@ -25,29 +23,14 @@ android {
namespace = "io.element.android.features.location.api"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
/**
* The "Send location" screen.
*
* Allows a user to share a location message within a room.
*/
interface SendLocationEntryPoint : SimpleFeatureEntryPoint

View file

@ -27,7 +27,7 @@ private const val STATIC_MAP_SCALE_2X = "@2x"
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
private const val STATIC_MAP_MAX_ZOOM = 22.0
internal fun buildTileServerUrl(
fun buildTileServerUrl(
darkMode: Boolean
): String = if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"

View file

@ -1,52 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.location.fake"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -17,7 +17,6 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@ -30,18 +29,18 @@ anvil {
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(libs.dagger)
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)

View file

@ -0,0 +1,32 @@
/*
* 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.location.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint {
override fun createNode(
parentNode: Node, buildContext: BuildContext
): SendLocationNode = parentNode.createNode(buildContext)
}

View file

@ -0,0 +1,22 @@
/*
* 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.location.impl
sealed interface SendLocationEvents {
data class ShareLocation(val lat: Double, val lng: Double) : SendLocationEvents
data class SwitchMode(val mode: SendLocationState.Mode) : SendLocationEvents
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class SendLocationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SendLocationPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
SendLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp,
)
}
}

View file

@ -0,0 +1,67 @@
/*
* 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.location.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.launch
import javax.inject.Inject
class SendLocationPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<SendLocationState> {
@Composable
override fun present(): SendLocationState {
val scope = rememberCoroutineScope()
var mode by remember {
mutableStateOf<SendLocationState.Mode>(SendLocationState.Mode.ALocation)
}
fun handleEvents(event: SendLocationEvents) {
when (event) {
is SendLocationEvents.ShareLocation -> scope.launch {
shareLocation(event)
}
is SendLocationEvents.SwitchMode -> {
mode = event.mode
}
}
}
return SendLocationState(
mode = mode,
eventSink = ::handleEvents,
)
}
private suspend fun shareLocation(
event: SendLocationEvents.ShareLocation
) {
room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${event.lng}",
)
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.location.impl
data class SendLocationState(
val mode: Mode = Mode.ALocation,
val eventSink: (SendLocationEvents) -> Unit = {},
) {
sealed interface Mode {
object MyLocation : Mode
object ALocation : Mode
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(),
)
}

View file

@ -0,0 +1,142 @@
/*
* 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.location.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.location.api.R
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SendLocationView(
state: SendLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState()
BottomSheetScaffold(
sheetContent = {
Spacer(modifier = Modifier.height(16.dp))
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
modifier = Modifier.clickable {
state.eventSink(
SendLocationEvents.ShareLocation(
lat = mapState.position.lat,
lng = mapState.position.lon
)
)
onBackPressed()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
},
)
Spacer(modifier = Modifier.height(16.dp))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_share_location_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapView(
modifier = Modifier.fillMaxSize(),
mapState = mapState,
)
Icon(
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified
)
}
}
}
@Preview
@Composable
internal fun SendLocationViewLightPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SendLocationViewDarkPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SendLocationState) {
SendLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.api
package io.element.android.features.location.impl.location
/**
* Represents a location sample emitted by the device's location subsystem.

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.location
import android.Manifest
import android.content.Context
@ -25,7 +25,6 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.location.api.Location
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking

View file

@ -14,15 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.location.api
package io.element.android.features.location.impl.map
import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -31,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
@ -47,11 +44,11 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import io.element.android.features.location.api.R
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.impl.location.Location
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -68,7 +65,6 @@ fun MapView(
modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.isLightTheme,
onLocationClick: () -> Unit,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@ -88,7 +84,10 @@ fun MapView(
LaunchedEffect(darkMode) {
mapView.awaitMap().let { map ->
map.uiSettings.apply {
attributionGravity = Gravity.TOP
logoGravity = Gravity.TOP
isCompassEnabled = false
isRotateGesturesEnabled = false
}
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
mapRefs = MapRefs(
@ -180,20 +179,10 @@ fun MapView(
}
@Suppress("ModifierReused")
Box(modifier = modifier) {
AndroidView(factory = { mapView })
FloatingActionButton(
onClick = onLocationClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = null, // TODO
)
}
}
AndroidView(
factory = { mapView },
modifier = modifier
)
}
@Composable
@ -292,6 +281,5 @@ private fun ContentToPreview() {
)
).toImmutableList()
),
onLocationClick = {},
)
}

View file

@ -0,0 +1,64 @@
/*
* 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.location.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SendLocationPresenterTest {
private val room = FakeMatrixRoom()
private val presenter = SendLocationPresenter(room)
@Test
fun `emits initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.ALocation)
}
}
@Test
fun `share location event shares a location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.ShareLocation(1.0, 2.0))
delay(1)
Truth.assertThat(room.sendLocationCount).isEqualTo(1)
}
}
@Test
fun `switches mode`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.SwitchMode(SendLocationState.Mode.MyLocation))
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.MyLocation)
}
}
}

View file

@ -14,21 +14,20 @@
* limitations under the License.
*/
package io.element.android.features.location.fake
package io.element.android.features.location.impl.location
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow {
while (true) {
delay(1_000)
emit(aLocation())
}
}
private fun aLocation() = Location(
private fun aLocation() = io.element.android.features.location.impl.location.Location(
lat = 51.49404,
lon = -0.25484,
accuracy = 5f

View file

@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@ -57,6 +58,7 @@ import kotlinx.parcelize.Parcelize
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -88,6 +90,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@Parcelize
object SendLocation : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@ -123,6 +128,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onReportMessage(eventId: EventId, senderId: UserId) {
backstack.push(NavTarget.ReportMessage(eventId, senderId))
}
override fun onSendLocationClicked() {
backstack.push(NavTarget.SendLocation)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
@ -155,6 +164,9 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs))
}
NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext)
}
}
}

View file

@ -57,6 +57,7 @@ class MessagesNode @AssistedInject constructor(
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
}
init {
@ -93,6 +94,10 @@ class MessagesNode @AssistedInject constructor(
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
callback?.onReportMessage(eventId, senderId)
}
private fun onSendLocationClicked() {
callback?.onSendLocationClicked()
}
@Composable
override fun View(modifier: Modifier) {
@ -104,6 +109,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onSendLocationClicked = this::onSendLocationClicked,
modifier = modifier,
)
}

View file

@ -65,6 +65,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
@ -91,6 +92,7 @@ fun MessagesView(
onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@ -152,7 +154,8 @@ fun MessagesView(
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
},
onReactionClicked = ::onEmojiReactionClicked
onReactionClicked = ::onEmojiReactionClicked,
onSendLocationClicked = onSendLocationClicked,
)
},
snackbarHost = {
@ -216,7 +219,15 @@ private fun AttachmentStateView(
is AttachmentsState.Previewing -> LaunchedEffect(state) {
onPreviewAttachments(state.attachments)
}
is AttachmentsState.Sending -> ProgressDialog(text = stringResource(id = CommonStrings.common_loading))
is AttachmentsState.Sending -> {
ProgressDialog(
type = when (state) {
is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
)
}
}
}
@ -228,6 +239,7 @@ fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -251,6 +263,7 @@ fun MessagesViewContent(
if (state.userHasPermissionToSendMessage) {
MessageComposerView(
state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
@ -338,5 +351,6 @@ private fun ContentToPreview(state: MessagesState) {
onEventClicked = {},
onPreviewAttachments = {},
onUserDataClicked = {},
onSendLocationClicked = {},
)
}

View file

@ -39,7 +39,6 @@ import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@ -48,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -87,8 +87,8 @@ fun ActionListView(
onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit,
onCustomReactionClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState()
) {
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
val targetItem = (state.target as? ActionListState.Target.Success)?.event
@ -119,22 +119,21 @@ fun ActionListView(
}
fun onDismiss() {
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
}
state.eventSink(ActionListEvents.Clear)
}
if (targetItem != null) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = ::onDismiss,
modifier = modifier,
) {
SheetContent(
state = state,
onActionClicked = ::onItemActionClicked,
onEmojiReactionClicked = ::onEmojiReactionClicked,
onCustomReactionClicked = ::onCustomReactionClicked,
modifier = modifier
modifier = Modifier
.padding(bottom = 32.dp)
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
@ -192,7 +191,7 @@ private fun SheetContent(
},
text = {
Text(
text = action.title,
text = stringResource(id = action.titleRes),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
},

View file

@ -17,20 +17,22 @@
package io.element.android.features.messages.impl.actionlist.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed class TimelineItemAction(
val title: String,
@StringRes val titleRes: Int,
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward)
object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
}

View file

@ -25,9 +25,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -48,13 +47,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
val sendActionState = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
mutableStateOf<SendActionState>(SendActionState.Idle)
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = Async.Uninitialized
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = SendActionState.Idle
}
}
@ -67,7 +66,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
sendActionState: MutableState<Async<Unit>>,
sendActionState: MutableState<SendActionState>,
) = launch {
when (attachment) {
is Attachment.Media -> {
@ -81,10 +80,26 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
sendActionState: MutableState<Async<Unit>>,
sendActionState: MutableState<SendActionState>,
) {
sendActionState.runUpdatingState {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
sendActionState.value = SendActionState.Sending.Processing
mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
compressIfPossible = mediaAttachment.compressIfPossible,
progressCallback = progressCallback
).fold(
onSuccess = {
sendActionState.value = SendActionState.Done
},
onFailure = {
sendActionState.value = SendActionState.Failure(it)
}
)
}
}

View file

@ -17,10 +17,21 @@
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
data class AttachmentsPreviewState(
val attachment: Attachment,
val sendActionState: Async<Unit>,
val sendActionState: SendActionState,
val eventSink: (AttachmentsPreviewEvents) -> Unit
)
sealed interface SendActionState {
object Idle : SendActionState
sealed interface Sending : SendActionState {
object Processing : Sending
data class Uploading(val progress: Float) : Sending
}
data class Failure(val error: Throwable) : SendActionState
object Done : SendActionState
}

View file

@ -22,23 +22,21 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.aFileInfo
import io.element.android.features.messages.impl.media.local.aVideoInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf(
anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aFileInfo()),
anAttachmentsPreviewState(sendActionState = Async.Loading()),
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException())),
)
}
fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageInfo(),
sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
sendActionState: SendActionState = SendActionState.Idle) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
compressIfPossible = true

View file

@ -33,9 +33,9 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -58,7 +58,7 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
}
if (state.sendActionState is Async.Success) {
if (state.sendActionState is SendActionState.Done) {
LaunchedEffect(state.sendActionState) {
onDismiss()
}
@ -78,26 +78,32 @@ fun AttachmentsPreviewView(
}
AttachmentSendStateView(
sendActionState = state.sendActionState,
onRetryClicked = ::postSendAttachment,
onRetryDismissed = ::postClearSendState
onDismissClicked = ::postClearSendState,
onRetryClicked = ::postSendAttachment
)
}
@Composable
private fun AttachmentSendStateView(
sendActionState: Async<Unit>,
onRetryDismissed: () -> Unit,
sendActionState: SendActionState,
onDismissClicked: () -> Unit,
onRetryClicked: () -> Unit
) {
when (sendActionState) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = CommonStrings.common_loading))
}
is Async.Failure -> {
when (sendActionState) {
is SendActionState.Sending -> {
ProgressDialog(
type = when (sendActionState) {
is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress)
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onRetryDismissed,
onDismiss = onDismissClicked,
onRetry = onRetryClicked
)
}

View file

@ -25,6 +25,7 @@ import androidx.compose.material.ListItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.ExperimentalMaterial3Api
@ -48,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun AttachmentsBottomSheet(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val localView = LocalView.current
@ -78,7 +80,10 @@ internal fun AttachmentsBottomSheet(
modifier = modifier,
onDismissRequest = { isVisible = false }
) {
AttachmentSourcePickerMenu(eventSink = state.eventSink)
AttachmentSourcePickerMenu(
eventSink = state.eventSink,
onSendLocationClicked = onSendLocationClicked,
)
}
}
}
@ -87,6 +92,7 @@ internal fun AttachmentsBottomSheet(
@Composable
internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -113,5 +119,13 @@ internal fun AttachmentSourcePickerMenu(
icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
ListItem(
modifier = Modifier.clickable {
eventSink(MessageComposerEvents.PickAttachmentSource.Location)
onSendLocationClicked()
},
icon = { Icon(Icons.Default.LocationOn, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
}

View file

@ -34,5 +34,6 @@ sealed interface MessageComposerEvents {
object FromFiles : PickAttachmentSource
object PhotoFromCamera : PickAttachmentSource
object VideoFromCamera : PickAttachmentSource
object Location : PickAttachmentSource
}
}

View file

@ -42,6 +42,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
@ -110,7 +111,7 @@ class MessageComposerPresenter @Inject constructor(
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
is AttachmentsState.Sending.Processing -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
else -> Unit
}
}
@ -158,6 +159,10 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
cameraVideoPicker.launch()
}
MessageComposerEvents.PickAttachmentSource.Location -> {
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
}
}
@ -245,7 +250,7 @@ class MessageComposerPresenter @Inject constructor(
attachmentsState.value = if (isPreviewable) {
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
} else {
AttachmentsState.Sending(persistentListOf(mediaAttachment))
AttachmentsState.Sending.Processing(persistentListOf(mediaAttachment))
}
}
@ -254,7 +259,12 @@ class MessageComposerPresenter @Inject constructor(
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) {
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false)
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback)
.onSuccess {
attachmentState.value = AttachmentsState.None
}.onFailure {

View file

@ -39,5 +39,8 @@ data class MessageComposerState(
sealed interface AttachmentsState {
object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
data class Sending(val attachments: ImmutableList<Attachment>) : AttachmentsState
sealed interface Sending : AttachmentsState {
data class Processing(val attachments: ImmutableList<Attachment>) : Sending
data class Uploading(val progress: Float) : Sending
}
}

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.textcomposer.TextComposer
@Composable
fun MessageComposerView(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onFullscreenToggle() {
@ -55,7 +56,10 @@ fun MessageComposerView(
}
Box {
AttachmentsBottomSheet(state = state)
AttachmentsBottomSheet(
state = state,
onSendLocationClicked = onSendLocationClicked,
)
TextComposer(
onSendMessage = ::sendMessage,
@ -83,5 +87,8 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
@Composable
private fun ContentToPreview(state: MessageComposerState) {
MessageComposerView(state)
MessageComposerView(
state = state,
onSendLocationClicked = {}
)
}

View file

@ -59,21 +59,16 @@ class TimelinePresenter @Inject constructor(
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems = timelineItemsFactory
.flow()
.collectAsState()
val paginationState = timeline
.paginationState()
.collectAsState()
val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState()
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems.value) ?: return
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
lastReadMarkerIndex = event.firstIndex
lastReadMarkerId = eventId
@ -85,11 +80,11 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) {
timeline
.timelineItems()
.timelineItems
.onEach(timelineItemsFactory::replaceWith)
.onEach { timelineItems ->
if (timelineItems.isEmpty()) {
loadMore(paginationState.value)
loadMore(paginationState)
}
}
.launchIn(this)
@ -97,8 +92,8 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value,
timelineItems = timelineItems.value,
paginationState = paginationState,
timelineItems = timelineItems,
eventSink = ::handleEvents
)
}

View file

@ -39,7 +39,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
@ -62,7 +61,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
@ -123,8 +121,7 @@ fun TimelineView(
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
onLoadMore = ::onReachedLoadMore
timelineItems = state.timelineItems
)
}
}
@ -222,7 +219,6 @@ fun TimelineItemRow(
internal fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
onLoadMore: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
@ -236,24 +232,6 @@ internal fun BoxScope.TimelineScrollHelper(
}
}
// Handle load more preloading
val loadMore by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 30)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
// Jump to bottom button
if (firstVisibleItemIndex > 2) {
FloatingActionButton(

View file

@ -44,6 +44,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.theme.ElementTheme
private val BUBBLE_RADIUS = 12.dp
@ -97,14 +99,11 @@ fun MessageEventBubble(
}
}
val backgroundBubbleColor = if (state.isHighlighted) {
ElementTheme.legacyColors.messageHighlightedBackground
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = if (state.isMine) {
ElementTheme.colors.messageFromMeBackground
} else {
if (state.isMine) {
ElementTheme.legacyColors.messageFromMeBackground
} else {
ElementTheme.legacyColors.messageFromOtherBackground
}
ElementTheme.colors.messageFromOtherBackground
}
val bubbleShape = bubbleShape()
Box(

View file

@ -41,18 +41,15 @@ private val CORNER_RADIUS = 8.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageStateEventContainer(
isHighlighted: Boolean,
@Suppress("UNUSED_PARAMETER") isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.legacyColors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
// Ignore isHighlighted for now, we need a design decision on it.
val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Surface(
modifier = modifier

View file

@ -58,15 +58,19 @@ fun TimelineEventTimestampView(
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(bounded = false),
interactionSource = MutableInteractionSource()
)
} else {
Modifier
}
Row(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = true,
indication = rememberRipple(bounded = false),
interactionSource = MutableInteractionSource()
)
.then(clickModifier)
.padding(start = 16.dp) // Add extra padding for touch target size
.then(modifier),
verticalAlignment = Alignment.CenterVertically,

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -60,7 +59,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.ElementTextStyles
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.AvatarData
@ -72,6 +70,8 @@ import io.element.android.libraries.matrix.api.core.UserId
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.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
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.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -271,7 +271,7 @@ private fun MessageEventBubbleContent(
}
} else {
Box(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp))
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
@ -319,7 +319,11 @@ private fun MessageEventBubbleContent(
val contentModifier = if (isMediaItem) {
Modifier.clip(RoundedCornerShape(12.dp))
} else {
Modifier
if (inReplyToDetails != null) {
Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
} else {
Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
}
ContentAndTimestampView(
@ -366,20 +370,19 @@ private fun ReplyToContent(
}
Column(verticalArrangement = Arrangement.SpaceBetween) {
Text(
senderName,
style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
text = senderName,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.primary,
color = ElementTheme.materialColors.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = text.orEmpty(),
style = ElementTextStyles.Regular.caption1,
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.legacyColors.placeholder,
maxLines = 1,
color = ElementTheme.materialColors.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
@ -458,6 +461,76 @@ private fun ContentToPreview() {
}
}
@Preview
@Composable
internal fun TimelineItemEventRowWithReplyLightPreview() =
ElementPreviewLight { ContentToPreviewWithReply() }
@Preview
@Composable
internal fun TimelineItemEventRowWithReplyDarkPreview() =
ElementPreviewDark { ContentToPreviewWithReply() }
@Composable
private fun ContentToPreviewWithReply() {
Column {
sequenceOf(false, true).forEach {
val replyContent = if(it) {
// Short
"Message which are being replied."
} else {
// Long, to test 2 lines and ellipsis)
"Message which are being replied, and which was long enough to be displayed on two lines (only!)."
}
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
inReplyTo = aInReplyToReady(replyContent)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {},
)
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
),
inReplyTo = aInReplyToReady(replyContent)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {},
)
}
}
}
private fun aInReplyToReady(
replyContent: String
): InReplyTo.Ready {
return InReplyTo.Ready(
eventId = EventId("\$event"),
content = MessageContent(replyContent, null, false, TextMessageType(replyContent, null)),
senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender",
senderAvatarUrl = null,
)
}
@Preview
@Composable
internal fun TimelineItemEventRowTimestampLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
@ -50,6 +51,7 @@ fun TimelineItemStateEventRow(
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {

View file

@ -38,9 +38,7 @@ fun CustomReactionBottomSheet(
val coroutineScope = rememberCoroutineScope()
fun onDismiss() {
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
}
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
}
fun onEmojiSelectedDismiss(emoji: Emoji) {

View file

@ -51,3 +51,11 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
// A space and a few unbreakable spaces
return ExtraPadding(" " + "\u00A0".repeat(strLen))
}
fun ExtraPadding.strBigger(): String {
return if (str.isEmpty()) {
str
} else {
str + "\u00A0\u00A0\u00A0"
}
}

View file

@ -59,7 +59,8 @@ fun TimelineItemInformativeView(
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
text = text + extraPadding.str
// Since the font size is smaller, add more space to extra padding, to not overlap with the timestamp
text = text + extraPadding.strBigger()
)
}
}

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@ -49,15 +48,12 @@ private val CORNER_RADIUS = 8.dp
fun GroupHeaderView(
text: String,
isExpanded: Boolean,
isHighlighted: Boolean,
@Suppress("UNUSED_PARAMETER") isHighlighted: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.legacyColors.messageHighlightedBackground
} else {
Color.Companion.Transparent
}
// Ignore isHighlighted for now, we need a design decision on it.
val backgroundColor = Color.Companion.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(

View file

@ -25,7 +25,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import kotlinx.coroutines.launch
@Composable

View file

@ -16,6 +16,9 @@
package io.element.android.features.messages.impl.timeline.factories
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.recyclerview.widget.DiffUtil
import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator
import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback
@ -27,11 +30,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -55,7 +55,10 @@ class TimelineItemsFactory @Inject constructor(
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<ImmutableList<TimelineItem>> = timelineItems.asStateFlow()
@Composable
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
return timelineItems.collectAsState()
}
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,

View file

@ -27,8 +27,8 @@ import io.element.android.features.messages.fixtures.aLocalMedia
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
@ -47,17 +47,26 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
val room = FakeMatrixRoom()
room.givenProgressCallbackValues(
listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
)
)
val presenter = anAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(Async.Success(Unit))
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@ -72,16 +81,16 @@ class AttachmentsPreviewPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(Async.Failure<Unit>(failure))
assertThat(failureState.sendActionState).isEqualTo((SendActionState.Failure(failure)))
assertThat(room.sendMediaCount).isEqualTo(0)
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
val clearedState = awaitItem()
assertThat(clearedState.sendActionState).isEqualTo(Async.Uninitialized)
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle)
}
}

View file

@ -368,6 +368,13 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick file from storage`() = runTest {
val room = FakeMatrixRoom()
room.givenProgressCallbackValues(
listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
)
)
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -376,7 +383,10 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0.5f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(room.sendMediaCount).isEqualTo(1)

View file

@ -47,6 +47,7 @@ dependencies {
api(projects.services.apperror.api)
implementation(libs.coil.compose)
implementation(projects.features.leaveroom.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -20,18 +20,21 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.services.analytics.api.AnalyticsService
import timber.log.Timber
import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ -41,6 +44,7 @@ class RoomDetailsNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val presenter: RoomDetailsPresenter,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
@ -51,6 +55,14 @@ class RoomDetailsNode @AssistedInject constructor(
private val callbacks = plugins<Callback>()
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomDetails))
}
)
}
private fun openRoomMemberList() {
callbacks.forEach { it.openRoomMemberList() }
}

View file

@ -38,8 +38,6 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -70,6 +68,9 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.LargeHeightPreview
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -192,12 +193,11 @@ internal fun RoomDetailsTopBar(
Icon(Icons.Default.MoreVert, "")
}
DropdownMenu(
modifier = Modifier.widthIn(200.dp),
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(id = CommonStrings.action_edit)) },
text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_edit)) },
onClick = {
// Explicitly close the menu before handling the action, as otherwise it stays open during the
// transition and renders really badly.

View file

@ -18,21 +18,33 @@ package io.element.android.features.roomdetails.impl.edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class RoomDetailsEditNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomDetailsEditPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomSettings))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View file

@ -19,16 +19,19 @@ package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -42,10 +45,19 @@ class RoomInviteMembersNode @AssistedInject constructor(
private val room: MatrixRoom,
private val presenter: RoomInviteMembersPresenter,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Invites))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View file

@ -18,21 +18,25 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
@ -42,6 +46,14 @@ class RoomMemberListNode @AssistedInject constructor(
private val callbacks = plugins<Callback>()
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomMembers))
}
)
}
private fun openRoomMemberDetails(roomMemberId: UserId) {
callbacks.forEach {
it.openRoomMemberDetails(roomMemberId)

View file

@ -19,11 +19,13 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
@ -32,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.services.analytics.api.AnalyticsService
import timber.log.Timber
import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ -39,6 +42,7 @@ import io.element.android.libraries.androidutils.R as AndroidUtilsR
class RoomMemberDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val analyticsService: AnalyticsService,
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
@ -49,6 +53,14 @@ class RoomMemberDetailsNode @AssistedInject constructor(
private val inputs = inputs<RoomMemberDetailsInput>()
private val presenter = presenterFactory.create(inputs.roomMemberId)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current

View file

@ -52,6 +52,7 @@ dependencies {
implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
implementation(projects.services.analytics.api)
implementation(libs.accompanist.placeholder)
api(projects.features.roomlist.api)
ksp(libs.showkase.processor)

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InvitesEntryPointView(
onInvitesClicked: () -> Unit,
state: InvitesState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(role = Role.Button, onClick = onInvitesClicked)
.padding(start = 24.dp, end = 16.dp)
.align(Alignment.CenterEnd)
.heightIn(min = 40.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(CommonStrings.action_invites_list),
style = ElementTheme.typography.fontBodyMdMedium,
)
if (state == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
UnreadIndicatorAtom()
}
}
}
}
@Preview
@Composable
internal fun InvitesEntryPointViewLightPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun InvitesEntryPointViewDarkPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: InvitesState) {
InvitesEntryPointView(
onInvitesClicked = {},
state = state,
)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class InvitesStateProvider : PreviewParameterProvider<InvitesState> {
override val values: Sequence<InvitesState>
get() = sequenceOf(
InvitesState.SeenInvites,
InvitesState.NewInvites,
)
}

View file

@ -20,18 +20,21 @@ import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
class RoomListNode @AssistedInject constructor(
@ -39,8 +42,17 @@ class RoomListNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
}
)
}
private fun onRoomClicked(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomClicked(roomId) }
}

View file

@ -17,18 +17,12 @@
package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -42,17 +36,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
@ -61,18 +52,15 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DrawableR
@Composable
@ -206,7 +194,7 @@ fun RoomListContent(
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state)
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
}
@ -251,38 +239,6 @@ fun RoomListContent(
)
}
@Composable
private fun InvitesEntryPointView(
onInvitesClicked: () -> Unit,
state: RoomListState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.clickable(role = Role.Button, onClick = onInvitesClicked)
.padding(horizontal = 16.dp)
.align(Alignment.CenterEnd)
.heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(CommonStrings.action_invites_list),
fontSize = 14.sp,
style = MaterialTheme.typography.bodyMedium,
)
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
UnreadIndicatorAtom()
}
}
}
}
internal fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview

View file

@ -19,12 +19,10 @@ package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
@ -48,6 +46,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
@ -58,6 +59,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@ -137,12 +139,20 @@ private fun DefaultRoomListTopBar(
IconButton(
onClick = onSearchClicked,
) {
Icon(Icons.Default.Search, contentDescription = stringResource(CommonStrings.action_search))
Icon(
imageVector = Icons.Default.Search,
tint = ElementTheme.materialColors.secondary,
contentDescription = stringResource(CommonStrings.action_search),
)
}
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(Icons.Default.MoreVert, contentDescription = null)
Icon(
imageVector = Icons.Default.MoreVert,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
@ -153,16 +163,28 @@ private fun DefaultRoomListTopBar(
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
Icons.Outlined.Share,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }
text = { DropdownMenuItemText(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = {
Icon(
Icons.Outlined.BugReport,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
},

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