Merge branch 'release/0.3.2'

This commit is contained in:
Jorge Martín 2023-11-22 10:34:27 +01:00
commit d13d4f4aa7
688 changed files with 6169 additions and 2493 deletions

View file

@ -55,7 +55,7 @@ jobs:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.3 - uses: rnkdsh/action-upload-diawi@v1.5.4
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
continue-on-error: true continue-on-error: true

View file

@ -40,7 +40,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- uses: mobile-dev-inc/action-maestro-cloud@v1.6.0 - uses: mobile-dev-inc/action-maestro-cloud@v1.8.0
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android): # Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):

View file

@ -16,7 +16,7 @@ appId: ${APP_ID}
- tapOn: - tapOn:
text: "Report a problem" text: "Report a problem"
- assertVisible: "Report a bug" - assertVisible: "Report a problem"
- back - back
- tapOn: - tapOn:

View file

@ -1,3 +1,28 @@
Changes in Element X v0.3.2 (2023-11-22)
========================================
Features ✨
----------
- Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/vector-im/element-x-android/issues/1158))
- Add support for typing mentions in the message composer. ([#1453](https://github.com/vector-im/element-x-android/issues/1453))
- Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/vector-im/element-x-android/issues/1591))
- Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/vector-im/element-x-android/issues/1784))
Bugfixes 🐛
----------
- Always ensure media temp dir exists ([#1790](https://github.com/vector-im/element-x-android/issues/1790))
Other changes
-------------
- Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/vector-im/element-x-android/issues/1718))
- Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/vector-im/element-x-android/issues/1801))
- LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/vector-im/element-x-android/issues/1806))
- Suppress usage of removeTimeline method. ([#1824](https://github.com/vector-im/element-x-android/issues/1824))
- Remove Element Call feature flag, it's now always enabled.
- Reverted the EC base URL to `https://call.element.io`.
- Moved the option to override this URL to developer settings from advanced settings.
Changes in Element X v0.3.1 (2023-11-09) Changes in Element X v0.3.1 (2023-11-09)
======================================== ========================================

View file

@ -23,7 +23,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
api(libs.anvil.compiler.api) api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils) implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.14.2") implementation(libs.kotlinpoet)
implementation(libs.dagger) implementation(libs.dagger)
compileOnly(libs.google.autoservice.annotations) compileOnly(libs.google.autoservice.annotations)
kapt(libs.google.autoservice) kapt(libs.google.autoservice)

View file

@ -27,8 +27,8 @@ plugins {
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.kapt) alias(libs.plugins.kapt)
id("com.google.firebase.appdistribution") version "4.0.1" alias(libs.plugins.firebaseAppDistribution)
id("org.jetbrains.kotlinx.knit") version "0.4.0" alias(libs.plugins.knit)
id("kotlin-parcelize") id("kotlin-parcelize")
// To be able to update the firebase.xml files, uncomment and build the project // To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services") // id("com.google.gms.google-services")

View file

@ -18,6 +18,7 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent import io.element.android.x.di.DaggerAppComponent
@ -27,17 +28,14 @@ import io.element.android.x.initializer.TracingInitializer
class ElementXApplication : Application(), DaggerComponentOwner { class ElementXApplication : Application(), DaggerComponentOwner {
private lateinit var appComponent: AppComponent override val daggerComponent: AppComponent = DaggerAppComponent.factory().create(this)
override val daggerComponent: Any
get() = appComponent
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appComponent = DaggerAppComponent.factory().create(applicationContext)
AppInitializer.getInstance(this).apply { AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java) initializeComponent(CrashInitializer::class.java)
initializeComponent(TracingInitializer::class.java) initializeComponent(TracingInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java)
} }
logApplicationInfo() logApplicationInfo()
} }

View file

@ -25,6 +25,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -33,10 +36,14 @@ import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.features.lockscreen.api.handleSecureFlag import io.element.android.features.lockscreen.api.handleSecureFlag
import io.element.android.features.lockscreen.api.isLocked
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.theme.Theme
import io.element.android.libraries.theme.theme.isDark
import io.element.android.libraries.theme.theme.mapToTheme
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler import io.element.android.x.intent.SafeUriHandler
import timber.log.Timber import timber.log.Timber
@ -46,7 +53,6 @@ private val loggerTag = LoggerTag("MainActivity")
class MainActivity : NodeActivity() { class MainActivity : NodeActivity() {
private lateinit var mainNode: MainNode private lateinit var mainNode: MainNode
private lateinit var appBindings: AppBindings private lateinit var appBindings: AppBindings
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -61,9 +67,29 @@ class MainActivity : NodeActivity() {
} }
} }
@Deprecated("")
override fun onBackPressed() {
// If the app is locked, we need to intercept onBackPressed before it goes to OnBackPressedDispatcher.
// Indeed, otherwise we would need to trick Appyx backstack management everywhere.
// Without this trick, we would get pop operations on the hidden backstack.
if (appBindings.lockScreenService().isLocked) {
// Do not kill the app in this case, just go to background.
moveTaskToBack(false)
} else {
@Suppress("DEPRECATION")
super.onBackPressed()
}
}
@Composable @Composable
private fun MainContent(appBindings: AppBindings) { private fun MainContent(appBindings: AppBindings) {
ElementTheme { val theme by remember {
appBindings.preferencesStore().getThemeFlow().mapToTheme()
}
.collectAsState(initial = Theme.System)
ElementTheme(
darkTheme = theme.isDark()
) {
CompositionLocalProvider( CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this), LocalUriHandler provides SafeUriHandler(this),

View file

@ -18,6 +18,7 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -29,4 +30,5 @@ interface AppBindings {
fun tracingService(): TracingService fun tracingService(): TracingService
fun bugReporter(): BugReporter fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService fun lockScreenService(): LockScreenService
fun preferencesStore(): PreferencesStore
} }

View file

@ -17,5 +17,5 @@
package io.element.android.appconfig package io.element.android.appconfig
object ElementCallConfig { object ElementCallConfig {
const val DEFAULT_BASE_URL = "https://call.element.dev" const val DEFAULT_BASE_URL = "https://call.element.io"
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.config package io.element.android.appconfig
object PushConfig { object PushConfig {
/** /**

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
object TimelineConfig {
const val maxReadReceiptToDisplay = 3
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
import kotlin.time.Duration.Companion.minutes
object VoiceMessageConfig {
val maxVoiceMessageDuration = 30.minutes
}

View file

@ -362,23 +362,18 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState() val lockScreenState by lockScreenStateService.lockState.collectAsState()
when (lockScreenState) { Children(
LockScreenLockState.Unlocked -> { navModel = backstack,
Children( modifier = Modifier,
navModel = backstack, // Animate navigation to settings and to a room
modifier = Modifier, transitionHandler = rememberDefaultTransitionHandler(),
// Animate navigation to settings and to a room )
transitionHandler = rememberDefaultTransitionHandler(), val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
) if (!isFtueDisplayed) {
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
if (!isFtueDisplayed) { }
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) if (lockScreenState == LockScreenLockState.Locked) {
} PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
}
LockScreenLockState.Locked -> {
MoveActivityToBackgroundBackHandler()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
}
} }
} }
} }

View file

@ -5,8 +5,8 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript { buildscript {
dependencies { dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20") classpath(libs.kotlin.gradle.plugin)
classpath("com.google.gms:google-services:4.4.0") classpath(libs.gms.google.services)
} }
} }

View file

@ -0,0 +1,7 @@
Main changes in this version:
- Element Call is now enabled by default.
- There is an 'ongoing call' indicator in the room list.
- Adding mentions to a message is now possible, but it's disabled by default as it's a work in progress. They can be enabled in the developer options.
- Voice messages behavior changed: there is no need to keep pressing to record, to start recording a message just tap on the mic button, then tap again to stop recording.
- Added a marker in the timeline to indicate the starting point of the room messages.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.R
import io.element.android.libraries.designsystem.components.LINK_TAG import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -30,7 +31,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSupportingText import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun AnalyticsPreferencesView( fun AnalyticsPreferencesView(
@ -42,18 +42,18 @@ fun AnalyticsPreferencesView(
} }
val supportingText = stringResource( val supportingText = stringResource(
id = CommonStrings.screen_analytics_settings_help_us_improve, id = R.string.screen_analytics_settings_help_us_improve,
state.applicationName state.applicationName
) )
val linkText = buildAnnotatedStringWithStyledPart( val linkText = buildAnnotatedStringWithStyledPart(
CommonStrings.screen_analytics_settings_read_terms, R.string.screen_analytics_settings_read_terms,
CommonStrings.screen_analytics_settings_read_terms_content_link, R.string.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl, tagAndLink = LINK_TAG to state.policyUrl,
) )
Column(modifier) { Column(modifier) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data)) Text(stringResource(id = R.string.screen_analytics_settings_share_data))
}, },
supportingContent = { supportingContent = {
Text(supportingText) Text(supportingText)

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Sdílet analytická data"</string>
<string name="screen_analytics_settings_help_us_improve">"Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy."</string>
<string name="screen_analytics_settings_read_terms">"Můžete si přečíst všechny naše podmínky %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"zde"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Analysedaten teilen"</string>
<string name="screen_analytics_settings_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_settings_read_terms">"Du kannst alle unsere Bedingungen lesen %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"hier"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Partagez des données de statistiques dutilisation"</string>
<string name="screen_analytics_settings_help_us_improve">"Partagez des données dutilisation anonymes pour nous aider à identifier les problèmes."</string>
<string name="screen_analytics_settings_read_terms">"Vous pouvez lire toutes nos conditions %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"ici"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Partajați datele analitice"</string>
<string name="screen_analytics_settings_help_us_improve">"Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string>
<string name="screen_analytics_settings_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"aici"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Делитесь данными аналитики"</string>
<string name="screen_analytics_settings_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string>
<string name="screen_analytics_settings_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"здесь"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Zdieľať analytické údaje"</string>
<string name="screen_analytics_settings_help_us_improve">"Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy."</string>
<string name="screen_analytics_settings_read_terms">"Môžete si prečítať všetky naše podmienky %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"tu"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"分享分析數據"</string>
<string name="screen_analytics_settings_help_us_improve">"分享匿名的使用數據以協助我們釐清問題。"</string>
<string name="screen_analytics_settings_read_terms">"您可以到%1$s閱讀我們的條款。"</string>
<string name="screen_analytics_settings_read_terms_content_link">"這裡"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
<string name="screen_analytics_settings_help_us_improve">"Share anonymous usage data to help us identify issues."</string>
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
</resources>

View file

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

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.cachecleaner.api
interface CacheCleaner {
/**
* Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages).
*
* Will fail silently in case of errors while deleting the files.
*/
fun clearCache()
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.cachecleaner.api
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface CacheCleanerBindings {
fun cacheCleaner(): CacheCleaner
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.cachecleaner.api
import android.content.Context
import androidx.startup.Initializer
import io.element.android.libraries.architecture.bindings
class CacheCleanerInitializer : Initializer<Unit> {
override fun create(context: Context) {
context.bindings<CacheCleanerBindings>().cacheCleaner().clearCache()
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.features.cachecleaner.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.cachecleaner.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(projects.tests.testutils)
}

View file

@ -0,0 +1,59 @@
/*
* 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.cachecleaner.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.cachecleaner.api.CacheCleaner
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.CacheDirectory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
/**
* Default implementation of [CacheCleaner].
*/
@ContributesBinding(AppScope::class)
class DefaultCacheCleaner @Inject constructor(
private val scope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
@CacheDirectory private val cacheDir: File,
) : CacheCleaner {
companion object {
val SUBDIRS_TO_CLEANUP = listOf("temp/media", "temp/voice")
}
override fun clearCache() {
scope.launch(dispatchers.io) {
runCatching {
SUBDIRS_TO_CLEANUP.forEach {
File(cacheDir.path, it).apply {
if (exists()) {
if (!deleteRecursively()) error("Failed to delete recursively cache directory $this")
}
if (!mkdirs()) error("Failed to create cache directory $this")
}
}
}.onFailure {
Timber.e(it, "Failed to clear cache")
}
}
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.cachecleaner.impl
import com.google.common.truth.Truth
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class DefaultCacheCleanerTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `calling clearCache actually removes file in the SUBDIRS_TO_CLEANUP list`() = runTest {
// Create temp subdirs and fill with 2 files each
DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach {
File(temporaryFolder.root, it).apply {
mkdirs()
File(this, "temp1").createNewFile()
File(this, "temp2").createNewFile()
}
}
// Clear cache
aCacheCleaner().clearCache()
// Check the files are gone but the sub dirs are not.
DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach {
File(temporaryFolder.root, it).apply {
Truth.assertThat(exists()).isTrue()
Truth.assertThat(isDirectory).isTrue()
Truth.assertThat(listFiles()).isEmpty()
}
}
}
@Test
fun `clear cache fails silently`() = runTest {
// Set cache dir as unreadable, unwritable and unexecutable so that the deletion fails.
check(temporaryFolder.root.setReadable(false))
check(temporaryFolder.root.setWritable(false))
check(temporaryFolder.root.setExecutable(false))
aCacheCleaner().clearCache()
}
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
scope = this,
dispatchers = this.testCoroutineDispatchers(true),
cacheDir = temporaryFolder.root,
)
}

View file

@ -31,15 +31,22 @@ import android.webkit.PermissionRequest
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import io.element.android.features.call.CallForegroundService import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.theme.Theme
import io.element.android.libraries.theme.theme.isDark
import io.element.android.libraries.theme.theme.mapToTheme
import javax.inject.Inject import javax.inject.Inject
class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator { class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
@ -60,6 +67,7 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
@Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var preferencesStore: PreferencesStore
private lateinit var presenter: CallScreenPresenter private lateinit var presenter: CallScreenPresenter
@ -92,8 +100,14 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
requestAudioFocus() requestAudioFocus()
setContent { setContent {
val theme by remember {
preferencesStore.getThemeFlow().mapToTheme()
}
.collectAsState(initial = Theme.System)
val state = presenter.present() val state = presenter.present()
ElementTheme { ElementTheme(
darkTheme = theme.isDark()
) {
CallScreenView( CallScreenView(
state = state, state = state,
requestPermissions = { permissions, callback -> requestPermissions = { permissions, callback ->

View file

@ -140,7 +140,7 @@ private fun CreateRoomActionButtonsList(
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
CreateRoomActionButton( CreateRoomActionButton(
iconRes = CommonDrawables.ic_groups, iconRes = CommonDrawables.ic_compound_plus,
text = stringResource(id = R.string.screen_create_room_action_create_room), text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked, onClick = onNewRoomClicked,
) )

View file

@ -48,19 +48,19 @@ private fun LeaveRoomConfirmationDialog(
when (state.confirmation) { when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {} is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_private_subtitle, text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_empty_subtitle, text = R.string.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_subtitle, text = R.string.leave_room_alert_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás."</string>
<string name="leave_room_alert_private_subtitle">"Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit."</string>
<string name="leave_room_alert_subtitle">"Opravdu chcete opustit místnost?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du austritst, kann in Zukunft niemand mehr eintreten, auch du nicht."</string>
<string name="leave_room_alert_private_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten."</string>
<string name="leave_room_alert_subtitle">"Bist du sicher, dass du den Raum verlassen willst?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú."</string>
<string name="leave_room_alert_private_subtitle">"¿Estás seguro de que quieres abandonar esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación."</string>
<string name="leave_room_alert_subtitle">"¿Seguro que quieres salir de la habitación?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à lavenir, y compris vous."</string>
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon nest pas public et vous ne pourrez pas le rejoindre sans invitation."</string>
<string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso."</string>
<string name="leave_room_alert_private_subtitle">"Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito."</string>
<string name="leave_room_alert_subtitle">"Sei sicuro di voler lasciare la stanza?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra."</string>
<string name="leave_room_alert_private_subtitle">"Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație."</string>
<string name="leave_room_alert_subtitle">"Sunteți sigur că vreți să părăsiți camera?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string>
<string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string>
<string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string>
<string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"您確定要離開聊天室嗎?這裡只有您一個人。如果您離開了,包含您在內的所有人都無法再進入此聊天室。"</string>
<string name="leave_room_alert_private_subtitle">"您確定要離開聊天室嗎?此聊天室不是公開的,如果沒有收到邀請,您無法重新加入。"</string>
<string name="leave_room_alert_subtitle">"您確定要離開聊天室嗎?"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Are you sure that you want to leave this room? You\'re the only person here. If you leave, no one will be able to join in the future, including you."</string>
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string>
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
</resources>

View file

@ -18,7 +18,6 @@ package io.element.android.features.location.impl.send
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
@ -27,9 +26,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
@ -68,7 +64,7 @@ import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SendLocationView( fun SendLocationView(
state: SendLocationState, state: SendLocationState,
@ -215,8 +211,8 @@ fun SendLocationView(
.padding(end = 16.dp, bottom = 72.dp + navBarPadding), .padding(end = 16.dp, bottom = 72.dp + navBarPadding),
) { ) {
when (state.mode) { when (state.mode) {
SendLocationState.Mode.PinLocation -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null) SendLocationState.Mode.PinLocation -> Icon(resourceId = CommonDrawables.ic_location_navigator, contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null) SendLocationState.Mode.SenderLocation -> Icon(resourceId = CommonDrawables.ic_location_navigator_centered, contentDescription = null)
} }
} }
} }

View file

@ -44,6 +44,12 @@ interface LockScreenService {
fun isPinSetup(): Flow<Boolean> fun isPinSetup(): Flow<Boolean>
} }
/**
* Check if the app is currently locked.
*/
val LockScreenService.isLocked: Boolean
get() = lockState.value == LockScreenLockState.Locked
/** /**
* Makes sure the secure flag is set on the activity if the pin is setup. * Makes sure the secure flag is set on the activity if the pin is setup.
* @param activity the activity to set the flag on. * @param activity the activity to set the flag on.

View file

@ -24,14 +24,14 @@
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Сэкономьте время и используйте %1$s для разблокировки приложения"</string> <string name="screen_app_lock_setup_biometric_unlock_subtitle">"Сэкономьте время и используйте %1$s для разблокировки приложения"</string>
<string name="screen_app_lock_setup_choose_pin">"Выберите PIN-код"</string> <string name="screen_app_lock_setup_choose_pin">"Выберите PIN-код"</string>
<string name="screen_app_lock_setup_confirm_pin">"Подтвердите PIN-код"</string> <string name="screen_app_lock_setup_confirm_pin">"Подтвердите PIN-код"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода."</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Выберите другой PIN-код"</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Выберите другой PIN-код"</string>
<string name="screen_app_lock_setup_pin_context">"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов. <string name="screen_app_lock_setup_pin_context">"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов.
Выберите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения."</string> Выберите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string>
<string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код."</string> <string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код"</string>
<string name="screen_app_lock_signout_alert_title">"Вы выходите из системы"</string> <string name="screen_app_lock_signout_alert_title">"Вы выходите из системы"</string>
<string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string> <string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string>
<string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string> <string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string>

View file

@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.20" alias(libs.plugins.kotlin.serialization)
} }
android { android {

View file

@ -35,8 +35,8 @@
<string name="screen_waitlist_message">"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова. <string name="screen_waitlist_message">"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова.
Спасибо за терпение!"</string> Спасибо за терпение!"</string>
<string name="screen_waitlist_title">"Почти готово!"</string> <string name="screen_waitlist_title">"Почти готово."</string>
<string name="screen_waitlist_title_success">"Вы зарегистрированы!"</string> <string name="screen_waitlist_title_success">"Вы зарегистрированы."</string>
<string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string> <string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>
<string name="screen_waitlist_message_success">"Добро пожаловать в %1$s!"</string> <string name="screen_waitlist_message_success">"Добро пожаловать в %1$s!"</string>
</resources> </resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string> <string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string> <string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string>
<string name="screen_signout_key_backup_disabled_title">"Vypnuli jste zálohování"</string> <string name="screen_signout_key_backup_disabled_title">"Vypnuli jste zálohování"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, můžete ztratit přístup k šifrovaným zprávám."</string> <string name="screen_signout_save_recovery_key_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, můžete ztratit přístup k šifrovaným zprávám."</string>
<string name="screen_signout_save_recovery_key_title">"Uložili jste si klíč pro obnovení?"</string> <string name="screen_signout_save_recovery_key_title">"Uložili jste si klíč pro obnovení?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string> <string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
<string name="screen_signout_preference_item">"Odhlásit se"</string> <string name="screen_signout_preference_item">"Odhlásit se"</string>
</resources> </resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Bist du sicher, dass du dich abmelden willst?"</string> <string name="screen_signout_confirmation_dialog_content">"Bist du sicher, dass du dich abmelden willst?"</string>
<string name="screen_signout_confirmation_dialog_title">"Abmelden"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string> <string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Abmelden"</string>
<string name="screen_signout_preference_item">"Abmelden"</string>
</resources> </resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"¿Estás seguro de que quieres cerrar sesión?"</string> <string name="screen_signout_confirmation_dialog_content">"¿Estás seguro de que quieres cerrar sesión?"</string>
<string name="screen_signout_confirmation_dialog_title">"Cerrar sesión"</string>
<string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string> <string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Cerrar sesión"</string>
<string name="screen_signout_preference_item">"Cerrar sesión"</string>
</resources> </resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Êtes-vous sûr de vouloir vous déconnecter ?"</string> <string name="screen_signout_confirmation_dialog_content">"Êtes-vous sûr de vouloir vous déconnecter ?"</string>
<string name="screen_signout_confirmation_dialog_title">"Se déconnecter"</string>
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string> <string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Vous êtes en train de vous déconnecter de votre dernière session. Si vous vous déconnectez maintenant, vous perdrez laccès à lhistorique de vos discussions chiffrées."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Vous êtes en train de vous déconnecter de votre dernière session. Si vous vous déconnectez maintenant, vous perdrez laccès à lhistorique de vos discussions chiffrées."</string>
<string name="screen_signout_key_backup_disabled_title">"Vous avez désactivé la sauvegarde"</string> <string name="screen_signout_key_backup_disabled_title">"Vous avez désactivé la sauvegarde"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"Vous êtes sur le point de vous déconnecter de votre dernière session. Si vous le faites maintenant, vous perdrez laccès à lhistorique de vos discussions chiffrées."</string> <string name="screen_signout_save_recovery_key_subtitle">"Vous êtes sur le point de vous déconnecter de votre dernière session. Si vous le faites maintenant, vous perdrez laccès à lhistorique de vos discussions chiffrées."</string>
<string name="screen_signout_save_recovery_key_title">"Avez-vous sauvegardé votre clé de récupération?"</string> <string name="screen_signout_save_recovery_key_title">"Avez-vous sauvegardé votre clé de récupération?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Se déconnecter"</string> <string name="screen_signout_confirmation_dialog_submit">"Se déconnecter"</string>
<string name="screen_signout_confirmation_dialog_title">"Se déconnecter"</string>
<string name="screen_signout_preference_item">"Se déconnecter"</string> <string name="screen_signout_preference_item">"Se déconnecter"</string>
</resources> </resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sei sicuro di voler uscire?"</string> <string name="screen_signout_confirmation_dialog_content">"Sei sicuro di voler uscire?"</string>
<string name="screen_signout_confirmation_dialog_title">"Esci"</string>
<string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string> <string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Esci"</string>
<string name="screen_signout_preference_item">"Esci"</string>
</resources> </resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sunteți sigur că vreți să vă deconectați?"</string> <string name="screen_signout_confirmation_dialog_content">"Sunteți sigur că vreți să vă deconectați?"</string>
<string name="screen_signout_confirmation_dialog_title">"Deconectați-vă"</string>
<string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string> <string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Deconectați-vă"</string>
<string name="screen_signout_preference_item">"Deconectați-vă"</string>
</resources> </resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string> <string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string> <string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_key_backup_disabled_title">"Вы отключили резервное копирование"</string> <string name="screen_signout_key_backup_disabled_title">"Вы отключили резервное копирование"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям."</string> <string name="screen_signout_save_recovery_key_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_save_recovery_key_title">"Вы сохранили свой ключ восстановления?"</string> <string name="screen_signout_save_recovery_key_title">"Вы сохранили свой ключ восстановления?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string> <string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
<string name="screen_signout_preference_item">"Выйти"</string> <string name="screen_signout_preference_item">"Выйти"</string>
</resources> </resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string> <string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string> <string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_key_backup_disabled_title">"Vypli ste zálohovanie"</string> <string name="screen_signout_key_backup_disabled_title">"Vypli ste zálohovanie"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string> <string name="screen_signout_save_recovery_key_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_save_recovery_key_title">"Uložili ste si kľúč na obnovenie?"</string> <string name="screen_signout_save_recovery_key_title">"Uložili ste si kľúč na obnovenie?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string> <string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
<string name="screen_signout_preference_item">"Odhlásiť sa"</string> <string name="screen_signout_preference_item">"Odhlásiť sa"</string>
</resources> </resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"您確定要登出嗎?"</string> <string name="screen_signout_confirmation_dialog_content">"您確定要登出嗎?"</string>
<string name="screen_signout_confirmation_dialog_title">"登出"</string>
<string name="screen_signout_in_progress_dialog_content">"正在登出…"</string> <string name="screen_signout_in_progress_dialog_content">"正在登出…"</string>
<string name="screen_signout_confirmation_dialog_submit">"登出"</string>
<string name="screen_signout_preference_item">"登出"</string>
</resources> </resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string> <string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string> <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages."</string> <string name="screen_signout_key_backup_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages."</string>
<string name="screen_signout_key_backup_disabled_title">"You have turned off backup"</string> <string name="screen_signout_key_backup_disabled_title">"You have turned off backup"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string> <string name="screen_signout_save_recovery_key_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string>
<string name="screen_signout_save_recovery_key_title">"Have you saved your recovery key?"</string> <string name="screen_signout_save_recovery_key_title">"Have you saved your recovery key?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string> <string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string> <string name="screen_signout_preference_item">"Sign out"</string>
</resources> </resources>

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.messages.api) api(projects.features.messages.api)
implementation(projects.appconfig)
implementation(projects.features.call) implementation(projects.features.call)
implementation(projects.features.location.api) implementation(projects.features.location.api)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)

View file

@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -97,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter, private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter, private val messageSummaryFormatter: MessageSummaryFormatter,
@ -124,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present() val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
@ -157,10 +160,10 @@ class MessagesPresenter @AssistedInject constructor(
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) } var enableVoiceMessages by remember { mutableStateOf(false) }
var enableInRoomCalls by remember { mutableStateOf(false) } // TODO add min power level to use this feature in the future?
val enableInRoomCalls = true
LaunchedEffect(featureFlagsService) { LaunchedEffect(featureFlagsService) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages) enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
} }
fun handleEvents(event: MessagesEvents) { fun handleEvents(event: MessagesEvents) {
@ -201,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState, retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -43,6 +44,7 @@ data class MessagesState(
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState, val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val inviteProgress: Async<Unit>, val inviteProgress: Async<Unit>,

View file

@ -24,9 +24,11 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -65,7 +67,14 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
aMessagesState().copy( aMessagesState().copy(
isCallOngoing = true, isCallOngoing = true,
) ),
aMessagesState().copy(
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(
voiceMessageState = aVoiceMessagePreviewState(),
showSendFailureDialog = true
),
),
) )
} }
@ -88,6 +97,10 @@ fun aMessagesState() = MessagesState(
selectedEvent = null, selectedEvent = null,
eventSink = {}, eventSink = {},
), ),
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(), actionListState = anActionListState(),
customReactionState = CustomReactionState( customReactionState = CustomReactionState(
target = CustomReactionState.Target.None, target = CustomReactionState.Target.None,

View file

@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -70,11 +71,14 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@ -211,6 +215,9 @@ fun MessagesView(
onReactionClicked = ::onEmojiReactionClicked, onReactionClicked = ::onEmojiReactionClicked,
onReactionLongClicked = ::onEmojiReactionLongClicked, onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked, onMoreReactionsClicked = ::onMoreReactionsClicked,
onReadReceiptClick = { event ->
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
},
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent -> onSwipeToReply = { targetEvent ->
@ -245,13 +252,12 @@ fun MessagesView(
) )
ReactionSummaryView(state = state.reactionSummaryState) ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu( RetrySendMessageMenu(state = state.retrySendMenuState)
state = state.retrySendMenuState ReadReceiptBottomSheet(
) state = state.readReceiptBottomSheetState,
onUserDataClicked = onUserDataClicked,
ReinviteDialog(
state = state
) )
ReinviteDialog(state = state)
// Since the textfield is now based on an Android view, this is no longer done automatically. // Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard automatically when navigating out of this screen. // We need to hide the keyboard automatically when navigating out of this screen.
@ -309,6 +315,7 @@ private fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
@ -340,15 +347,20 @@ private fun MessagesViewContent(
appName = state.appName appName = state.appName
) )
} }
if (state.enableVoiceMessages && state.voiceMessageComposerState.showSendFailureDialog) {
VoiceMessageSendingFailedDialog(
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) },
)
}
// This key is used to force the sheet to be remeasured when the content changes. // This key is used to force the sheet to be remeasured when the content changes.
// Any state change that should trigger a height size should be added to the list of remembered values here. // Any state change that should trigger a height size should be added to the list of remembered values here.
val sheetResizeContentKey = remember( val sheetResizeContentKey = remember { mutableIntStateOf(0) }
state.composerState.mode.relatedEventId, LaunchedEffect(
state.composerState.richTextEditorState.lineCount, state.composerState.richTextEditorState.lineCount,
state.composerState.memberSuggestions.size state.composerState.showTextFormatting,
) { ) {
Random.nextInt() sheetResizeContentKey.intValue = Random.nextInt()
} }
ExpandableBottomSheetScaffold( ExpandableBottomSheetScaffold(
@ -367,6 +379,7 @@ private fun MessagesViewContent(
TimelineView( TimelineView(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
state = state.timelineState, state = state.timelineState,
roomName = state.roomName.dataOrNull(),
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
@ -374,6 +387,7 @@ private fun MessagesViewContent(
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked, onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked, onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )
}, },
@ -383,7 +397,7 @@ private fun MessagesViewContent(
state = state, state = state,
) )
}, },
sheetContentKey = sheetResizeContentKey, sheetContentKey = sheetResizeContentKey.intValue,
sheetTonalElevation = 0.dp, sheetTonalElevation = 0.dp,
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp, sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
) )
@ -399,7 +413,8 @@ private fun MessagesViewComposerBottomSheetContents(
if (state.userHasPermissionToSendMessage) { if (state.userHasPermissionToSendMessage) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
MentionSuggestionsPickerView( MentionSuggestionsPickerView(
modifier = Modifier.heightIn(max = 230.dp) modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection { .nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@ -411,7 +426,7 @@ private fun MessagesViewComposerBottomSheetContents(
roomAvatarData = state.roomAvatar.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(),
memberSuggestions = state.composerState.memberSuggestions, memberSuggestions = state.composerState.memberSuggestions,
onSuggestionSelected = { onSuggestionSelected = {
// TODO pass the selected suggestion to the RTE so it can be inserted as a pill state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
} }
) )
MessageComposerView( MessageComposerView(

View file

@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -33,9 +35,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -70,12 +69,17 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
@ -140,15 +144,13 @@ fun ActionListView(
onEmojiReactionClicked = ::onEmojiReactionClicked, onEmojiReactionClicked = ::onEmojiReactionClicked,
onCustomReactionClicked = ::onCustomReactionClicked, onCustomReactionClicked = ::onCustomReactionClicked,
modifier = Modifier modifier = Modifier
.padding(bottom = 32.dp) .navigationBarsPadding()
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 .imePadding()
// .imePadding()
) )
} }
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun SheetContent( private fun SheetContent(
state: ActionListState, state: ActionListState,
@ -198,18 +200,13 @@ private fun SheetContent(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onActionClicked(action) onActionClicked(action)
}, },
text = { headlineContent = {
Text( Text(text = stringResource(id = action.titleRes))
text = stringResource(id = action.titleRes),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}, },
icon = { leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
Icon( style = when {
resourceId = action.icon, action.destructive -> ListItemStyle.Destructive
contentDescription = "", else -> ListItemStyle.Primary
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
} }
) )
} }
@ -373,7 +370,7 @@ private fun EmojiReactionsRow(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
resourceId = CommonDrawables.ic_september_add_reaction, resourceId = CommonDrawables.ic_add_reaction,
contentDescription = "Emojis", contentDescription = "Emojis",
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier modifier = Modifier
@ -410,8 +407,7 @@ private fun EmojiButton(
) { ) {
Text( Text(
emoji, emoji,
fontSize = 24.dp.toSp(), style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
color = Color.White,
modifier = Modifier modifier = Modifier
.clickable( .clickable(
enabled = true, enabled = true,

View file

@ -28,13 +28,13 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val destructive: Boolean = false val destructive: Boolean = false
) { ) {
data object Forward : TimelineItemAction(CommonStrings.action_forward, CommonDrawables.ic_september_forward) data object Forward : TimelineItemAction(CommonStrings.action_forward, CommonDrawables.ic_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CommonDrawables.ic_september_copy) data object Copy : TimelineItemAction(CommonStrings.action_copy, CommonDrawables.ic_copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CommonDrawables.ic_compound_delete, destructive = true) data object Redact : TimelineItemAction(CommonStrings.action_remove, CommonDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CommonDrawables.ic_september_reply) data object Reply : TimelineItemAction(CommonStrings.action_reply, CommonDrawables.ic_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CommonDrawables.ic_september_reply) data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CommonDrawables.ic_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CommonDrawables.ic_september_edit_outline) data object Edit : TimelineItemAction(CommonStrings.action_edit, CommonDrawables.ic_edit_outline)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_september_view_source) data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CommonDrawables.ic_compound_chat_problem, destructive = true) data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CommonDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CommonDrawables.ic_poll_end) data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CommonDrawables.ic_poll_end)
} }

View file

@ -236,7 +236,7 @@ private fun MediaFileView(
) { ) {
Icon( Icon(
imageVector = if (isAudio) Icons.Outlined.GraphicEq else null, imageVector = if (isAudio) Icons.Outlined.GraphicEq else null,
resourceId = if (isAudio) null else CommonDrawables.ic_september_attachment, resourceId = if (isAudio) null else CommonDrawables.ic_attachment,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.background, tint = MaterialTheme.colorScheme.background,
modifier = Modifier modifier = Modifier

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.messages.impl.mentions
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.RoomMember
@Immutable
sealed interface MentionSuggestion {
data object Room : MentionSuggestion
data class Member(val roomMember: RoomMember) : MentionSuggestion
}

View file

@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -52,8 +51,8 @@ fun MentionSuggestionsPickerView(
roomId: RoomId, roomId: RoomId,
roomName: String?, roomName: String?,
roomAvatarData: AvatarData?, roomAvatarData: AvatarData?,
memberSuggestions: ImmutableList<RoomMemberSuggestion>, memberSuggestions: ImmutableList<MentionSuggestion>,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit, onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@ -63,8 +62,8 @@ fun MentionSuggestionsPickerView(
memberSuggestions, memberSuggestions,
key = { suggestion -> key = { suggestion ->
when (suggestion) { when (suggestion) {
is RoomMemberSuggestion.Room -> "@room" is MentionSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value is MentionSuggestion.Member -> suggestion.roomMember.userId.value
} }
} }
) { ) {
@ -85,18 +84,18 @@ fun MentionSuggestionsPickerView(
@Composable @Composable
private fun RoomMemberSuggestionItemView( private fun RoomMemberSuggestionItemView(
memberSuggestion: RoomMemberSuggestion, memberSuggestion: MentionSuggestion,
roomId: String, roomId: String,
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit, onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val avatarSize = AvatarSize.TimelineRoom val avatarSize = AvatarSize.TimelineRoom
val avatarData = when (memberSuggestion) { val avatarData = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is RoomMemberSuggestion.Member -> AvatarData( is MentionSuggestion.Member -> AvatarData(
memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.userId.value,
memberSuggestion.roomMember.displayName, memberSuggestion.roomMember.displayName,
memberSuggestion.roomMember.avatarUrl, memberSuggestion.roomMember.avatarUrl,
@ -104,13 +103,13 @@ private fun RoomMemberSuggestionItemView(
) )
} }
val title = when (memberSuggestion) { val title = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
} }
val subtitle = when (memberSuggestion) { val subtitle = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> "@room" is MentionSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
} }
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@ -159,9 +158,9 @@ internal fun MentionSuggestionsPickerView_Preview() {
roomName = "Room", roomName = "Room",
roomAvatarData = null, roomAvatarData = null,
memberSuggestions = persistentListOf( memberSuggestions = persistentListOf(
RoomMemberSuggestion.Room, MentionSuggestion.Room,
RoomMemberSuggestion.Member(roomMember), MentionSuggestion.Member(roomMember),
RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
), ),
onSuggestionSelected = {} onSuggestionSelected = {}
) )

View file

@ -16,7 +16,7 @@
package io.element.android.features.messages.impl.mentions package io.element.android.features.messages.impl.mentions
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion import io.element.android.libraries.core.data.filterUpTo
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -46,10 +46,8 @@ object MentionSuggestionsProcessor {
roomMembersState: MatrixRoomMembersState, roomMembersState: MatrixRoomMembersState,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean, canSendRoomMention: suspend () -> Boolean,
): List<RoomMemberSuggestion> { ): List<MentionSuggestion> {
val members = roomMembersState.roomMembers() val members = roomMembersState.roomMembers()
// Take the first MAX_BATCH_ITEMS only
?.take(MAX_BATCH_ITEMS)
return when { return when {
members.isNullOrEmpty() || suggestion == null -> { members.isNullOrEmpty() || suggestion == null -> {
// Clear suggestions // Clear suggestions
@ -61,7 +59,7 @@ object MentionSuggestionsProcessor {
// Replace suggestions // Replace suggestions
val matchingMembers = getMemberSuggestions( val matchingMembers = getMemberSuggestions(
query = suggestion.text, query = suggestion.text,
roomMembers = roomMembersState.roomMembers(), roomMembers = members,
currentUserId = currentUserId, currentUserId = currentUserId,
canSendRoomMention = canSendRoomMention() canSendRoomMention = canSendRoomMention()
) )
@ -81,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List<RoomMember>?, roomMembers: List<RoomMember>?,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: Boolean, canSendRoomMention: Boolean,
): List<RoomMemberSuggestion> { ): List<MentionSuggestion> {
return if (roomMembers.isNullOrEmpty()) { return if (roomMembers.isNullOrEmpty()) {
emptyList() emptyList()
} else { } else {
@ -95,14 +93,14 @@ object MentionSuggestionsProcessor {
} }
val matchingMembers = roomMembers val matchingMembers = roomMembers
// Search only in joined members, exclude the current user // Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user
.filter { member -> .filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
} }
.map(RoomMemberSuggestion::Member) .map(MentionSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) { if ("room".contains(query) && canSendRoomMention) {
listOf(RoomMemberSuggestion.Room) + matchingMembers listOf(MentionSuggestion.Room) + matchingMembers
} else { } else {
matchingMembers matchingMembers
} }

View file

@ -19,9 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.ListItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -33,12 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
@ -93,7 +94,6 @@ internal fun AttachmentsBottomSheet(
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun AttachmentSourcePickerMenu( private fun AttachmentSourcePickerMenu(
state: MessageComposerState, state: MessageComposerState,
@ -103,28 +103,33 @@ private fun AttachmentSourcePickerMenu(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier.padding(bottom = 32.dp) modifier = modifier
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 .navigationBarsPadding()
.imePadding()
) { ) {
ListItem( ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(CommonDrawables.ic_september_photo_video_library, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_image)),
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
style = ListItemStyle.Primary,
) )
ListItem( ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(CommonDrawables.ic_september_attachment, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_attachment)),
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
style = ListItemStyle.Primary,
) )
ListItem( ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(CommonDrawables.ic_september_take_photo_camera, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_take_photo_camera, )),
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
style = ListItemStyle.Primary,
) )
ListItem( ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(CommonDrawables.ic_september_video_call, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_video_call)),
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
style = ListItemStyle.Primary,
) )
if (state.canShareLocation) { if (state.canShareLocation) {
ListItem( ListItem(
@ -132,8 +137,9 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Location) state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
onSendLocationClicked() onSendLocationClicked()
}, },
icon = { Icon(CommonDrawables.ic_september_location, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_location_pin) ),
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
style = ListItemStyle.Primary,
) )
} }
if (state.canCreatePoll) { if (state.canCreatePoll) {
@ -142,15 +148,17 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll) state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
onCreatePollClicked() onCreatePollClicked()
}, },
icon = { Icon(CommonDrawables.ic_compound_polls, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_compound_polls)),
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
style = ListItemStyle.Primary,
) )
} }
if (enableTextFormatting) { if (enableTextFormatting) {
ListItem( ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
icon = { Icon(CommonDrawables.ic_september_text_formatting, null) }, leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_text_formatting, null)),
text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) }, headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
style = ListItemStyle.Primary,
) )
} }
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -41,4 +42,5 @@ sealed interface MessageComposerEvents {
data object CancelSendAttachment : MessageComposerEvents data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents data class Error(val error: Throwable) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
} }

View file

@ -20,7 +20,6 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -36,6 +35,7 @@ import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -45,8 +45,9 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -67,6 +68,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -87,7 +90,7 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder, private val currentSessionIdHolder: CurrentSessionIdHolder,
permissionsPresenterFactory: PermissionsPresenter.Factory permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@ -173,7 +176,7 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
val memberSuggestions = remember { mutableStateListOf<RoomMemberSuggestion>() } val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
LaunchedEffect(isMentionsEnabled) { LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current val currentUserId = currentSessionIdHolder.current
@ -184,8 +187,11 @@ class MessageComposerPresenter @Inject constructor(
return !roomIsDm && userCanSendAtRoom return !roomIsDm && userCanSendAtRoom
} }
suggestionSearchTrigger // This will trigger a search immediately when `@` is typed
.debounce(0.5.seconds) val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
// This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
val mentionCompletionTrigger = suggestionSearchTrigger.filter { !it?.text.isNullOrEmpty() }.debounce(0.3.seconds)
merge(mentionStartTrigger, mentionCompletionTrigger)
.combine(room.membersStateFlow) { suggestion, roomMembersState -> .combine(room.membersStateFlow) { suggestion, roomMembersState ->
memberSuggestions.clear() memberSuggestions.clear()
val result = MentionSuggestionsProcessor.process( val result = MentionSuggestionsProcessor.process(
@ -284,6 +290,20 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SuggestionReceived -> { is MessageComposerEvents.SuggestionReceived -> {
suggestionSearchTrigger.value = event.suggestion suggestionSearchTrigger.value = event.suggestion
} }
is MessageComposerEvents.InsertMention -> {
localCoroutineScope.launch {
when (val mention = event.mention) {
is MentionSuggestion.Room -> {
richTextEditorState.insertAtRoomMentionAtSuggestion()
}
is MentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
}
}
}
} }
} }
@ -297,6 +317,7 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -307,15 +328,25 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState: RichTextEditorState, richTextEditorState: RichTextEditorState,
) = launch { ) = launch {
val capturedMode = messageComposerContext.composerMode val capturedMode = messageComposerContext.composerMode
val mentions = richTextEditorState.mentionsState?.let { state ->
buildList {
if (state.hasAtRoomMention) {
add(Mention.AtRoom)
}
for (userId in state.userIds) {
add(Mention.User(userId))
}
}
}.orEmpty()
// Reset composer right away // Reset composer right away
richTextEditorState.setHtml("") richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal) updateComposerMode(MessageComposerMode.Normal)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html) is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions)
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, message.markdown, message.html) room.editMessage(eventId, transactionId, message.markdown, message.html, mentions)
} }
is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Quote -> TODO()
@ -323,6 +354,7 @@ class MessageComposerPresenter @Inject constructor(
capturedMode.eventId, capturedMode.eventId,
message.markdown, message.markdown,
message.html, message.html,
mentions
) )
} }
analyticsService.capture( analyticsService.capture(
@ -410,8 +442,3 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
@Immutable
sealed interface RoomMemberSuggestion {
data object Room : RoomMemberSuggestion
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
}

View file

@ -19,6 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -33,7 +35,8 @@ data class MessageComposerState(
val canShareLocation: Boolean, val canShareLocation: Boolean,
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<RoomMemberSuggestion>, val memberSuggestions: ImmutableList<MentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) { ) {
val hasFocus: Boolean = richTextEditorState.hasFocus val hasFocus: Boolean = richTextEditorState.hasFocus

View file

@ -17,6 +17,8 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -38,7 +40,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true, canShareLocation: Boolean = true,
canCreatePoll: Boolean = true, canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None, attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(), memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
richTextEditorState = composerState, richTextEditorState = composerState,
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
@ -49,5 +51,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {}, eventSink = {},
) )

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
@ -32,9 +33,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.PressEvent
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -46,6 +47,7 @@ internal fun MessageComposerView(
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val view = LocalView.current
fun sendMessage(message: Message) { fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message)) state.eventSink(MessageComposerEvents.SendMessage(message))
} }
@ -59,6 +61,7 @@ internal fun MessageComposerView(
} }
fun onDismissTextFormatting() { fun onDismissTextFormatting() {
view.clearFocus()
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false)) state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
} }
@ -77,8 +80,8 @@ internal fun MessageComposerView(
} }
} }
val onVoiceRecordButtonEvent = { press: PressEvent -> val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press)) voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press))
} }
val onSendVoiceMessage = { val onSendVoiceMessage = {
@ -107,12 +110,13 @@ internal fun MessageComposerView(
onDismissTextFormatting = ::onDismissTextFormatting, onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting, enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent, onVoiceRecorderEvent = onVoiceRecorderEvent,
onVoicePlayerEvent = onVoicePlayerEvent, onVoicePlayerEvent = onVoicePlayerEvent,
onSendVoiceMessage = onSendVoiceMessage, onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onSuggestionReceived = ::onSuggestionReceived, onSuggestionReceived = ::onSuggestionReceived,
onError = ::onError, onError = ::onError,
currentUserId = state.currentUserId,
) )
} }

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.async.AsyncView import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@ -101,14 +102,14 @@ fun ReportMessageView(
OutlinedTextField( OutlinedTextField(
value = state.reason, value = state.reason,
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) }, onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
placeholder = { Text(stringResource(CommonStrings.report_content_hint)) }, placeholder = { Text(stringResource(R.string.report_content_hint)) },
enabled = !isSending, enabled = !isSending,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 90.dp) .heightIn(min = 90.dp)
) )
Text( Text(
text = stringResource(CommonStrings.report_content_explanation), text = stringResource(R.string.report_content_explanation),
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@ -122,11 +123,11 @@ fun ReportMessageView(
) { ) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = stringResource(CommonStrings.screen_report_content_block_user), text = stringResource(R.string.screen_report_content_block_user),
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
) )
Text( Text(
text = stringResource(CommonStrings.screen_report_content_block_user_hint), text = stringResource(R.string.screen_report_content_block_user_hint),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )

View file

@ -32,13 +32,17 @@ import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -63,6 +67,8 @@ class TimelinePresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val verificationService: SessionVerificationService, private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val timeline = room.timeline private val timeline = room.timeline
@ -97,6 +103,9 @@ class TimelinePresenter @Inject constructor(
} }
} }
val readReceiptsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.ReadReceipts).collectAsState(initial = false)
val membersState by room.membersStateFlow.collectAsState()
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -136,12 +145,23 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timeline timeline
.timelineItems .timelineItems
.onEach(timelineItemsFactory::replaceWith) .onEach {
timelineItemsFactory.replaceWith(
timelineItems = it,
roomMembers = if (readReceiptsEnabled) {
membersState.roomMembers().orEmpty()
} else {
// Give an empty list to not affect performance
emptyList()
}
)
}
.onEach { timelineItems -> .onEach { timelineItems ->
if (timelineItems.isEmpty()) { if (timelineItems.isEmpty()) {
paginateBackwards() paginateBackwards()
} }
} }
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.launchIn(this) .launchIn(this)
} }
@ -150,6 +170,7 @@ class TimelinePresenter @Inject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value, hasNewItems = hasNewItems.value,
sessionState = sessionState, sessionState = sessionState,
eventSink = ::handleEvents eventSink = ::handleEvents

View file

@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class TimelineState( data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val showReadReceipts: Boolean,
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,

View file

@ -16,9 +16,11 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@ -43,7 +45,12 @@ import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), showReadReceipts = false,
paginationState = MatrixTimeline.PaginationState(
isBackPaginating = false,
hasMoreToLoadBackwards = true,
beginningOfRoomReached = false,
),
highlightedEventId = null, highlightedEventId = null,
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
hasNewItems = false, hasNewItems = false,
@ -114,11 +121,12 @@ internal fun aTimelineItemEvent(
senderDisplayName: String = "Sender", senderDisplayName: String = "Sender",
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId), sendState: LocalEventSendState? = null,
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@ -128,6 +136,7 @@ internal fun aTimelineItemEvent(
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender), senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content, content = content,
reactionsState = timelineItemReactions, reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
senderDisplayName = senderDisplayName, senderDisplayName = senderDisplayName,
@ -169,6 +178,12 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
return TimelineItemReadReceipts(
receipts = emptyList<ReadReceiptData>().toImmutableList(),
)
}
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent( val event = aTimelineItemEvent(
isMine = true, isMine = true,

View file

@ -59,6 +59,7 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
@ -82,6 +83,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun TimelineView( fun TimelineView(
state: TimelineState, state: TimelineState,
roomName: String?,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
@ -90,6 +92,7 @@ fun TimelineView(
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -124,6 +127,9 @@ fun TimelineView(
) { timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
showReadReceipts = state.showReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true
&& state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage, userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked, onClick = onMessageClicked,
@ -133,6 +139,7 @@ fun TimelineView(
onReactionClick = onReactionClicked, onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked, onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked, onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState, sessionState = state.sessionState,
eventSink = state.eventSink, eventSink = state.eventSink,
@ -148,6 +155,11 @@ fun TimelineView(
} }
} }
} }
if (state.paginationState.beginningOfRoomReached) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
} }
TimelineScrollHelper( TimelineScrollHelper(
@ -162,6 +174,8 @@ fun TimelineView(
@Composable @Composable
private fun TimelineItemRow( private fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
userHasPermissionToSendMessage: Boolean, userHasPermissionToSendMessage: Boolean,
sessionState: SessionState, sessionState: SessionState,
@ -172,6 +186,7 @@ private fun TimelineItemRow(
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@ -198,6 +213,8 @@ private fun TimelineItemRow(
} else { } else {
TimelineItemEventRow( TimelineItemEventRow(
event = timelineItem, event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(), canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
@ -207,6 +224,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink, eventSink = eventSink,
@ -237,6 +255,8 @@ private fun TimelineItemRow(
timelineItem.events.forEach { subGroupEvent -> timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState, sessionState = sessionState,
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
@ -248,6 +268,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink, eventSink = eventSink,
onSwipeToReply = {}, onSwipeToReply = {},
) )
@ -346,6 +367,7 @@ internal fun TimelineViewPreview(
) { ) {
TimelineView( TimelineView(
state = aTimelineState(timelineItems), state = aTimelineState(timelineItems),
roomName = null,
onMessageClicked = {}, onMessageClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onUserDataClicked = {}, onUserDataClicked = {},
@ -354,6 +376,7 @@ internal fun TimelineViewPreview(
onReactionLongClicked = { _, _ -> }, onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {}, onMoreReactionsClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onReadReceiptClick = {},
) )
} }
} }

View file

@ -181,7 +181,7 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP
@Composable @Composable
internal fun MessagesAddReactionButtonPreview() = ElementPreview { internal fun MessagesAddReactionButtonPreview() = ElementPreview {
MessagesReactionButton( MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_september_add_reaction), content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
onClick = {}, onClick = {},
onLongClick = {} onLongClick = {}
) )

View file

@ -49,7 +49,7 @@ fun RowScope.ReplySwipeIndicator(
alpha = swipeProgress() alpha = swipeProgress()
}, },
contentDescription = null, contentDescription = null,
resourceId = CommonDrawables.ic_september_reply, resourceId = CommonDrawables.ic_reply,
) )
} }

View file

@ -66,6 +66,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -114,6 +116,8 @@ import kotlin.math.roundToInt
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
canReply: Boolean, canReply: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -124,6 +128,7 @@ fun TimelineItemEventRow(
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -173,6 +178,8 @@ fun TimelineItemEventRow(
state = state.draggableState, state = state.draggableState,
), ),
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -183,6 +190,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -190,6 +198,8 @@ fun TimelineItemEventRow(
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -200,6 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -232,6 +243,8 @@ private fun SwipeSensitivity(
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
@ -240,6 +253,7 @@ private fun TimelineItemEventRowContent(
inReplyToClicked: () -> Unit, inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit, onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReadReceiptsClicked: () -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@ -256,7 +270,12 @@ private fun TimelineItemEventRowContent(
.wrapContentHeight() .wrapContentHeight()
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
val (sender, message, reactions) = createRefs() val (
sender,
message,
reactions,
readReceipts,
) = createRefs()
// Sender // Sender
val avatarStrokeSize = 3.dp val avatarStrokeSize = 3.dp
@ -322,6 +341,25 @@ private fun TimelineItemEventRowContent(
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
) )
} }
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = onReadReceiptsClicked,
modifier = Modifier
.constrainAs(readReceipts) {
if (event.reactionsState.reactions.isNotEmpty()) {
top.linkTo(reactions.bottom, margin = 4.dp)
} else {
top.linkTo(message.bottom, margin = 4.dp)
}
}
)
} }
} }
@ -383,22 +421,6 @@ private fun MessageEventBubbleContent(
// to its `combinedClickable` parent so we do it manually // to its `combinedClickable` parent so we do it manually
fun onTimestampLongClick() = onMessageLongClick() fun onTimestampLongClick() = onMessageLongClick()
@Composable
fun ContentView(
modifier: Modifier = Modifier
) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
interactionSource = interactionSource,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(),
eventSink = eventSink,
modifier = modifier,
)
}
@Composable @Composable
fun ThreadDecoration( fun ThreadDecoration(
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -422,21 +444,20 @@ private fun MessageEventBubbleContent(
} }
@Composable @Composable
fun ContentAndTimestampView( fun WithTimestampLayout(
timestampPosition: TimestampPosition, timestampPosition: TimestampPosition,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier, content: @Composable () -> Unit,
timestampModifier: Modifier = Modifier,
) { ) {
when (timestampPosition) { when (timestampPosition) {
TimestampPosition.Overlay -> TimestampPosition.Overlay ->
Box(modifier) { Box(modifier) {
ContentView(modifier = contentModifier) content()
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onClick = onTimestampClicked, onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick, onLongClick = ::onTimestampLongClick,
modifier = timestampModifier modifier = Modifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding .padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp)) .background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
@ -445,24 +466,24 @@ private fun MessageEventBubbleContent(
} }
TimestampPosition.Aligned -> TimestampPosition.Aligned ->
Box(modifier) { Box(modifier) {
ContentView(modifier = contentModifier) content()
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onClick = onTimestampClicked, onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick, onLongClick = ::onTimestampLongClick,
modifier = timestampModifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
} }
TimestampPosition.Below -> TimestampPosition.Below ->
Column(modifier) { Column(modifier) {
ContentView(modifier = contentModifier) content()
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onClick = onTimestampClicked, onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick, onLongClick = ::onTimestampLongClick,
modifier = timestampModifier modifier = Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
@ -478,52 +499,77 @@ private fun MessageEventBubbleContent(
inReplyToDetails: InReplyTo.Ready?, inReplyToDetails: InReplyTo.Ready?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val modifierWithPadding: Modifier val timestampLayoutModifier: Modifier
val contentModifier: Modifier val contentModifier: Modifier
when { when {
inReplyToDetails != null -> { inReplyToDetails != null -> {
if (timestampPosition == TimestampPosition.Overlay) { if (timestampPosition == TimestampPosition.Overlay) {
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
contentModifier = Modifier.clip(RoundedCornerShape(12.dp)) contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
} else { } else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp) contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
modifierWithPadding = Modifier timestampLayoutModifier = Modifier
} }
} }
timestampPosition != TimestampPosition.Overlay -> { timestampPosition != TimestampPosition.Overlay -> {
modifierWithPadding = Modifier timestampLayoutModifier = Modifier
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp) contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
} }
else -> { else -> {
modifierWithPadding = Modifier timestampLayoutModifier = Modifier
contentModifier = Modifier contentModifier = Modifier
} }
} }
val threadDecoration = @Composable {
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
if (showThreadDecoration) { if (showThreadDecoration) {
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp)) ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
} }
if (inReplyToDetails != null) { }
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value val contentWithTimestamp = @Composable {
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails) WithTimestampLayout(
val text = textForInReplyTo(inReplyToDetails) timestampPosition = timestampPosition,
val topPadding = if (showThreadDecoration) 0.dp else 8.dp modifier = timestampLayoutModifier,
ReplyToContent( ) {
senderName = senderName, TimelineItemEventContentView(
text = text, content = event.content,
attachmentThumbnailInfo = attachmentThumbnailInfo, isMine = event.isMine,
modifier = Modifier interactionSource = interactionSource,
.padding(top = topPadding, start = 8.dp, end = 8.dp) onClick = onMessageClick,
.clip(RoundedCornerShape(6.dp)) onLongClick = onMessageLongClick,
.clickable(enabled = true, onClick = inReplyToClick), extraPadding = event.toExtraPadding(),
eventSink = eventSink,
modifier = contentModifier,
) )
} }
ContentAndTimestampView( }
timestampPosition = timestampPosition, val inReplyTo = @Composable { inReplyToReady: InReplyTo.Ready ->
modifier = modifierWithPadding, val senderName = inReplyToReady.senderDisplayName ?: inReplyToReady.senderId.value
contentModifier = contentModifier, val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToReady)
val text = textForInReplyTo(inReplyToReady)
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
) )
}
if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
threadDecoration()
inReplyTo(inReplyToDetails)
contentWithTimestamp()
}
} else {
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
threadDecoration()
contentWithTimestamp()
}
} }
} }
@ -650,6 +696,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
), ),
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -659,6 +707,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -671,6 +720,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
), ),
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -680,6 +731,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -710,6 +762,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
inReplyTo = aInReplyToReady(replyContent), inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -719,6 +773,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -733,6 +788,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
isThreaded = true, isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -742,6 +799,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -784,6 +842,8 @@ internal fun TimelineItemEventRowTimestampPreview(
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case", senderDisplayName = if (useDocument) "Document case" else "Text case",
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -793,6 +853,7 @@ internal fun TimelineItemEventRowTimestampPreview(
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -816,6 +877,8 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
), ),
timelineItemReactions = aTimelineItemReactions(count = 20), timelineItemReactions = aTimelineItemReactions(count = 20),
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -825,6 +888,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},
@ -841,6 +905,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
event = aTimelineItemEvent( event = aTimelineItemEvent(
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line", senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -850,6 +916,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},
@ -862,6 +929,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight { internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent(content = aTimelineItemPollContent()), event = aTimelineItemEvent(content = aTimelineItemPollContent()),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -871,6 +940,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},

View file

@ -196,7 +196,7 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
}, },
addMoreButton = { addMoreButton = {
MessagesReactionButton( MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_september_add_reaction), content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
onClick = {}, onClick = {},
onLongClick = {} onLongClick = {}
) )

View file

@ -69,7 +69,7 @@ private fun TimelineItemReactionsView(
onToggleExpandClick: () -> Unit, onToggleExpandClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. // In LTR languages we want an incoming message's reactions to be LTR and outgoing to be RTL.
// For RTL languages it should be the opposite. // For RTL languages it should be the opposite.
val currentLayout = LocalLayoutDirection.current val currentLayout = LocalLayoutDirection.current
val reactionsLayoutDirection = if (!isOutgoing) currentLayout val reactionsLayoutDirection = if (!isOutgoing) currentLayout
@ -95,7 +95,7 @@ private fun TimelineItemReactionsView(
}, },
addMoreButton = { addMoreButton = {
MessagesReactionButton( MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_september_add_reaction), content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
onClick = onMoreReactionsClick, onClick = onMoreReactionsClick,
onLongClick = {} onLongClick = {}
) )

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
@ -34,7 +35,7 @@ fun TimelineItemVirtualRow(
) { ) {
when (virtual.model) { when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier) is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier)
} }
} }

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -47,7 +48,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun EmojiPicker( fun EmojiPicker(
onEmojiSelected: (Emoji) -> Unit, onEmojiSelected: (Emoji) -> Unit,

View file

@ -28,31 +28,29 @@ import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.features.messages.impl.R
@get:StringRes @get:StringRes
val EmojibaseCategory.title: Int get() = val EmojibaseCategory.title: Int
when(this){ get() = when (this) {
EmojibaseCategory.People -> CommonStrings.emoji_picker_category_people EmojibaseCategory.People -> R.string.emoji_picker_category_people
EmojibaseCategory.Nature -> CommonStrings.emoji_picker_category_nature EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
EmojibaseCategory.Foods -> CommonStrings.emoji_picker_category_foods EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
EmojibaseCategory.Activity -> CommonStrings.emoji_picker_category_activity EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
EmojibaseCategory.Places -> CommonStrings.emoji_picker_category_places EmojibaseCategory.Places -> R.string.emoji_picker_category_places
EmojibaseCategory.Objects -> CommonStrings.emoji_picker_category_objects EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
EmojibaseCategory.Symbols -> CommonStrings.emoji_picker_category_symbols EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
EmojibaseCategory.Flags -> CommonStrings.emoji_picker_category_flags EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
} }
val EmojibaseCategory.icon: ImageVector val EmojibaseCategory.icon: ImageVector
get() = get() = when (this) {
when(this){ EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags }
}

View file

@ -35,7 +35,7 @@ fun TimelineItemEncryptedView(
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_waiting_for_decryption_key), text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CommonDrawables.ic_september_decryption_error, iconResourceId = CommonDrawables.ic_waiting_to_decrypt,
extraPadding = extraPadding, extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )

View file

@ -59,7 +59,7 @@ fun TimelineItemFileView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
resourceId = CommonDrawables.ic_september_attachment, resourceId = CommonDrawables.ic_attachment,
contentDescription = "OpenFile", contentDescription = "OpenFile",
tint = ElementTheme.materialColors.primary, tint = ElementTheme.materialColors.primary,
modifier = Modifier modifier = Modifier

View file

@ -27,6 +27,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -53,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
@Composable @Composable
fun TimelineItemVoiceView( fun TimelineItemVoiceView(
@ -90,7 +96,7 @@ fun TimelineItemVoiceView(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
val context = LocalContext.current val context = LocalContext.current
WaveformPlaybackView( WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause, showCursor = state.showCursor,
playbackProgress = state.progress, playbackProgress = state.progress,
waveform = content.waveform, waveform = content.waveform,
modifier = Modifier modifier = Modifier
@ -147,19 +153,40 @@ private fun RetryButton(
} }
} }
/**
* Progress button is shown when the voice message is being downloaded.
*
* The progress indicator is optimistic and displays a pause button (which
* indicates the audio is playing) for 2 seconds before revealing the
* actual progress indicator.
*/
@Composable @Composable
private fun ProgressButton() { private fun ProgressButton(
displayImmediately: Boolean = false,
) {
var canDisplay by remember { mutableStateOf(displayImmediately) }
LaunchedEffect(Unit) {
delay(2000L)
canDisplay = true
}
CustomIconButton( CustomIconButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
) { ) {
CircularProgressIndicator( if (canDisplay) {
modifier = Modifier CircularProgressIndicator(
.padding(2.dp) modifier = Modifier
.size(16.dp), .padding(2.dp)
color = ElementTheme.colors.iconSecondary, .size(16.dp),
strokeWidth = 2.dp, color = ElementTheme.colors.iconSecondary,
) strokeWidth = 2.dp,
)
} else {
Icon(
resourceId = R.drawable.pause,
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
} }
} }
@ -228,3 +255,12 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
} }
} }
} }
@PreviewsDayNight
@Composable
internal fun ProgressButtonPreview() = ElementPreview {
Row {
ProgressButton(displayImmediately = true)
ProgressButton(displayImmediately = false)
}
}

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.messages.impl.timeline.components.receipt
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
data class ReadReceiptViewState(
val sendState: LocalEventSendState?,
val isLastOutgoingMessage: Boolean,
val receipts: ImmutableList<ReadReceiptData>,
)

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.receipt
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList
class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewState> {
override val values: Sequence<ReadReceiptViewState>
get() = sequenceOf(
aReadReceiptViewState(),
aReadReceiptViewState(sendState = LocalEventSendState.NotSentYet),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } },
),
)
}
private fun aReadReceiptViewState(
sendState: LocalEventSendState? = null,
isLastOutgoingMessage: Boolean = true,
receipts: List<ReadReceiptData> = emptyList(),
) = ReadReceiptViewState(
sendState = sendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = receipts.toImmutableList(),
)
private fun aReadReceiptData(
index: Int,
avatarData: AvatarData = anAvatarData(
id = "$index",
size = AvatarSize.TimelineReadReceipt
),
formattedDate: String = "12:34",
) = ReadReceiptData(
avatarData = avatarData,
formattedDate = formattedDate,
)

View file

@ -0,0 +1,211 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.receipt
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.appconfig.TimelineConfig
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.getBestName
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TimelineItemReadReceiptView(
state: ReadReceiptViewState,
showReadReceipts: Boolean,
onReadReceiptsClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.receipts.isNotEmpty()) {
if (showReadReceipts) {
ReadReceiptsRow(modifier = modifier) {
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { onReadReceiptsClicked() }
.padding(2.dp)
)
}
}
} else when (state.sendState) {
LocalEventSendState.NotSentYet -> {
ReadReceiptsRow(modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sending,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
}
}
LocalEventSendState.Canceled -> Unit
is LocalEventSendState.SendingFailed -> {
// Error? The timestamp is already displayed in red
}
null,
is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) {
ReadReceiptsRow(modifier = modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sent,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
}
}
}
}
}
@Composable
private fun ReadReceiptsRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(AvatarSize.TimelineReadReceipt.dp + 8.dp)
.padding(horizontal = 18.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
) {
content()
}
}
}
@Composable
private fun ReadReceiptsAvatars(
receipts: ImmutableList<ReadReceiptData>,
modifier: Modifier = Modifier
) {
val avatarSize = AvatarSize.TimelineReadReceipt.dp
val avatarStrokeSize = 1.dp
val avatarStrokeColor = MaterialTheme.colorScheme.background
val receiptDescription = computeReceiptDescription(receipts)
Row(
modifier = modifier
.clearAndSetSemantics {
stateDescription = receiptDescription
},
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.CenterEnd,
) {
receipts
.take(TimelineConfig.maxReadReceiptToDisplay)
.reversed()
.forEachIndexed { index, readReceiptData ->
Box(
modifier = Modifier
.padding(end = (12.dp + avatarStrokeSize * 2) * index)
.size(size = avatarSize + avatarStrokeSize * 2)
.clip(CircleShape)
.background(avatarStrokeColor)
.zIndex(index.toFloat()),
contentAlignment = Alignment.Center,
) {
Avatar(
avatarData = readReceiptData.avatarData,
)
}
}
}
if (receipts.size > TimelineConfig.maxReadReceiptToDisplay) {
Text(
text = "+" + (receipts.size - TimelineConfig.maxReadReceiptToDisplay),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
@Composable
private fun computeReceiptDescription(receipts: ImmutableList<ReadReceiptData>): String {
return when (receipts.size) {
0 -> "" // Cannot happen
1 -> stringResource(
id = CommonStrings.a11y_read_receipts_single,
receipts[0].avatarData.getBestName()
)
2 -> stringResource(
id = CommonStrings.a11y_read_receipts_multiple,
receipts[0].avatarData.getBestName(),
receipts[1].avatarData.getBestName(),
)
else -> pluralStringResource(
id = CommonPlurals.a11y_read_receipts_multiple_with_others,
count = receipts.size - 1,
receipts[0].avatarData.getBestName(),
receipts.size - 1
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemReactionsViewPreview(
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
) = ElementPreview {
TimelineItemReadReceiptView(
state = state,
showReadReceipts = true,
onReadReceiptsClicked = {},
)
}

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ReadReceiptBottomSheet(
state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = state.selectedEvent != null
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
sheetState = sheetState,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
state.eventSink(ReadReceiptBottomSheetEvents.Dismiss)
}
}
) {
ReadReceiptBottomSheetContent(
state = state,
onUserDataClicked = {
coroutineScope.launch {
sheetState.hide()
state.eventSink(ReadReceiptBottomSheetEvents.Dismiss)
onUserDataClicked.invoke(it)
}
},
)
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
private fun ColumnScope.ReadReceiptBottomSheetContent(
state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit,
) {
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
val receipts = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
receipts.forEach {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
}
}
@PreviewsDayNight
@Composable
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column {
ReadReceiptBottomSheetContent(
state = state,
onUserDataClicked = {},
)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ReadReceiptBottomSheetEvents {
data class EventSelected(val event: TimelineItem.Event) : ReadReceiptBottomSheetEvents
data object Dismiss : ReadReceiptBottomSheetEvents
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class ReadReceiptBottomSheetPresenter @Inject constructor(
) : Presenter<ReadReceiptBottomSheetState> {
@Composable
override fun present(): ReadReceiptBottomSheetState {
var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
fun handleEvent(event: ReadReceiptBottomSheetEvents) {
@Suppress("LiftReturnOrAssignment")
when (event) {
is ReadReceiptBottomSheetEvents.EventSelected -> {
selectedEvent = event.event
}
ReadReceiptBottomSheetEvents.Dismiss -> {
selectedEvent = null
}
}
}
return ReadReceiptBottomSheetState(
selectedEvent = selectedEvent,
eventSink = { handleEvent(it) },
)
}
}

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.messages.impl.timeline.components.receipt.bottomsheet
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@Immutable
data class ReadReceiptBottomSheetState(
val selectedEvent: TimelineItem.Event?,
val eventSink: (ReadReceiptBottomSheetEvents) -> Unit,
)

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