Merge branch 'release/0.3.2'

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

View file

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

View file

@ -40,7 +40,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_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:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):

View file

@ -16,7 +16,7 @@ appId: ${APP_ID}
- tapOn:
text: "Report a problem"
- assertVisible: "Report a bug"
- assertVisible: "Report a problem"
- back
- 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)
========================================

View file

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

View file

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

View file

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

View file

@ -25,6 +25,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.platform.LocalUriHandler
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.plugin.NodeReadyObserver
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.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
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.intent.SafeUriHandler
import timber.log.Timber
@ -46,7 +53,6 @@ private val loggerTag = LoggerTag("MainActivity")
class MainActivity : NodeActivity() {
private lateinit var mainNode: MainNode
private lateinit var appBindings: AppBindings
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
private fun MainContent(appBindings: AppBindings) {
ElementTheme {
val theme by remember {
appBindings.preferencesStore().getThemeFlow().mapToTheme()
}
.collectAsState(initial = Theme.System)
ElementTheme(
darkTheme = theme.isDark()
) {
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this),

View file

@ -18,6 +18,7 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
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.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@ -29,4 +30,5 @@ interface AppBindings {
fun tracingService(): TracingService
fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
fun preferencesStore(): PreferencesStore
}

View file

@ -17,5 +17,5 @@
package io.element.android.appconfig
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.
*/
package io.element.android.libraries.push.impl.config
package io.element.android.appconfig
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) {
Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState()
when (lockScreenState) {
LockScreenLockState.Unlocked -> {
Children(
navModel = backstack,
modifier = Modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
}
LockScreenLockState.Locked -> {
MoveActivityToBackgroundBackHandler()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
}
Children(
navModel = backstack,
modifier = Modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
if (lockScreenState == LockScreenLockState.Locked) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
}
}
}

View file

@ -5,8 +5,8 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20")
classpath("com.google.gms:google-services:4.4.0")
classpath(libs.kotlin.gradle.plugin)
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.tooling.preview.PreviewParameter
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.list.ListItemContent
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.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AnalyticsPreferencesView(
@ -42,18 +42,18 @@ fun AnalyticsPreferencesView(
}
val supportingText = stringResource(
id = CommonStrings.screen_analytics_settings_help_us_improve,
id = R.string.screen_analytics_settings_help_us_improve,
state.applicationName
)
val linkText = buildAnnotatedStringWithStyledPart(
CommonStrings.screen_analytics_settings_read_terms,
CommonStrings.screen_analytics_settings_read_terms_content_link,
R.string.screen_analytics_settings_read_terms,
R.string.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl,
)
Column(modifier) {
ListItem(
headlineContent = {
Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data))
Text(stringResource(id = R.string.screen_analytics_settings_share_data))
},
supportingContent = {
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.result.ActivityResultLauncher
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.remember
import androidx.core.content.IntentCompat
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
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.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
class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
@ -60,6 +67,7 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var preferencesStore: PreferencesStore
private lateinit var presenter: CallScreenPresenter
@ -92,8 +100,14 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
requestAudioFocus()
setContent {
val theme by remember {
preferencesStore.getThemeFlow().mapToTheme()
}
.collectAsState(initial = Theme.System)
val state = presenter.present()
ElementTheme {
ElementTheme(
darkTheme = theme.isDark()
) {
CallScreenView(
state = state,
requestPermissions = { permissions, callback ->

View file

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

View file

@ -48,19 +48,19 @@ private fun LeaveRoomConfirmationDialog(
when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_private_subtitle,
text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_empty_subtitle,
text = R.string.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = CommonStrings.leave_room_alert_subtitle,
text = R.string.leave_room_alert_subtitle,
roomId = state.confirmation.roomId,
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.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.navigationBars
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.ListItem
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.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendLocationView(
state: SendLocationState,
@ -215,8 +211,8 @@ fun SendLocationView(
.padding(end = 16.dp, bottom = 72.dp + navBarPadding),
) {
when (state.mode) {
SendLocationState.Mode.PinLocation -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
SendLocationState.Mode.PinLocation -> Icon(resourceId = CommonDrawables.ic_location_navigator, 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>
}
/**
* 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.
* @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_choose_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_context">"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов.
Выберите что-нибудь незабываемое. Если вы забудете этот 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_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_use_biometric_android">"Использовать биометрию"</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.ksp)
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.20"
alias(libs.plugins.kotlin.serialization)
}
android {

View file

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

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Odhlásit se"</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_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_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_title">"Odhlásit se"</string>
<string name="screen_signout_preference_item">"Odhlásit se"</string>
</resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"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>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Cerrar 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>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Se déconnecter"</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_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_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_title">"Se déconnecter"</string>
<string name="screen_signout_preference_item">"Se déconnecter"</string>
</resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Esci"</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>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Deconectați-vă"</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>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Выйти"</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_title">"Вы отключили резервное копирование"</string>
@ -14,5 +13,6 @@
<string name="screen_signout_save_recovery_key_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_save_recovery_key_title">"Вы сохранили свой ключ восстановления?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
<string name="screen_signout_preference_item">"Выйти"</string>
</resources>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Odhlásiť sa"</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_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_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_title">"Odhlásiť sa"</string>
<string name="screen_signout_preference_item">"Odhlásiť sa"</string>
</resources>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"登出"</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>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Sign 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_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_title">"Have you saved your recovery key?"</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>
</resources>

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
implementation(projects.appconfig)
implementation(projects.features.call)
implementation(projects.features.location.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.components.customreaction.CustomReactionPresenter
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.model.TimelineItem
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 reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
@ -124,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
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)
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) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
}
fun handleEvents(event: MessagesEvents) {
@ -201,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
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.components.customreaction.CustomReactionState
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.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async
@ -43,6 +44,7 @@ data class MessagesState(
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
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.components.customreaction.CustomReactionState
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.model.event.aTimelineItemTextContent
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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -65,7 +67,14 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
),
aMessagesState().copy(
isCallOngoing = true,
)
),
aMessagesState().copy(
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(
voiceMessageState = aVoiceMessagePreviewState(),
showSendFailureDialog = true
),
),
)
}
@ -88,6 +97,10 @@ fun aMessagesState() = MessagesState(
selectedEvent = null,
eventSink = {},
),
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
target = CustomReactionState.Target.None,

View file

@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.reactionsummary.ReactionSummaryEvents
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.RetrySendMessageMenu
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.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@ -211,6 +215,9 @@ fun MessagesView(
onReactionClicked = ::onEmojiReactionClicked,
onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onReadReceiptClick = { event ->
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
},
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent ->
@ -245,13 +252,12 @@ fun MessagesView(
)
ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu(
state = state.retrySendMenuState
)
ReinviteDialog(
state = state
RetrySendMessageMenu(state = state.retrySendMenuState)
ReadReceiptBottomSheet(
state = state.readReceiptBottomSheetState,
onUserDataClicked = onUserDataClicked,
)
ReinviteDialog(state = state)
// 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.
@ -309,6 +315,7 @@ private fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
@ -340,15 +347,20 @@ private fun MessagesViewContent(
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.
// Any state change that should trigger a height size should be added to the list of remembered values here.
val sheetResizeContentKey = remember(
state.composerState.mode.relatedEventId,
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
LaunchedEffect(
state.composerState.richTextEditorState.lineCount,
state.composerState.memberSuggestions.size
state.composerState.showTextFormatting,
) {
Random.nextInt()
sheetResizeContentKey.intValue = Random.nextInt()
}
ExpandableBottomSheetScaffold(
@ -367,6 +379,7 @@ private fun MessagesViewContent(
TimelineView(
modifier = Modifier.padding(paddingValues),
state = state.timelineState,
roomName = state.roomName.dataOrNull(),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
@ -374,6 +387,7 @@ private fun MessagesViewContent(
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
)
},
@ -383,7 +397,7 @@ private fun MessagesViewContent(
state = state,
)
},
sheetContentKey = sheetResizeContentKey,
sheetContentKey = sheetResizeContentKey.intValue,
sheetTonalElevation = 0.dp,
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
)
@ -399,7 +413,8 @@ private fun MessagesViewComposerBottomSheetContents(
if (state.userHasPermissionToSendMessage) {
Column(modifier = modifier.fillMaxWidth()) {
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
.nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@ -411,7 +426,7 @@ private fun MessagesViewComposerBottomSheetContents(
roomAvatarData = state.roomAvatar.dataOrNull(),
memberSuggestions = state.composerState.memberSuggestions,
onSuggestionSelected = {
// TODO pass the selected suggestion to the RTE so it can be inserted as a pill
state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
}
)
MessageComposerView(

View file

@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.size
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.shape.CircleShape
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.material3.ExperimentalMaterial3Api
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.libraries.designsystem.components.avatar.Avatar
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.PreviewsDayNight
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.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.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
@ -140,15 +144,13 @@ fun ActionListView(
onEmojiReactionClicked = ::onEmojiReactionClicked,
onCustomReactionClicked = ::onCustomReactionClicked,
modifier = Modifier
.padding(bottom = 32.dp)
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
.navigationBarsPadding()
.imePadding()
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SheetContent(
state: ActionListState,
@ -198,18 +200,13 @@ private fun SheetContent(
modifier = Modifier.clickable {
onActionClicked(action)
},
text = {
Text(
text = stringResource(id = action.titleRes),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
headlineContent = {
Text(text = stringResource(id = action.titleRes))
},
icon = {
Icon(
resourceId = action.icon,
contentDescription = "",
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
style = when {
action.destructive -> ListItemStyle.Destructive
else -> ListItemStyle.Primary
}
)
}
@ -373,7 +370,7 @@ private fun EmojiReactionsRow(
contentAlignment = Alignment.Center
) {
Icon(
resourceId = CommonDrawables.ic_september_add_reaction,
resourceId = CommonDrawables.ic_add_reaction,
contentDescription = "Emojis",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
@ -410,8 +407,7 @@ private fun EmojiButton(
) {
Text(
emoji,
fontSize = 24.dp.toSp(),
color = Color.White,
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
modifier = Modifier
.clickable(
enabled = true,

View file

@ -28,13 +28,13 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
data object Forward : TimelineItemAction(CommonStrings.action_forward, CommonDrawables.ic_september_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CommonDrawables.ic_september_copy)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CommonDrawables.ic_forward)
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 Reply : TimelineItemAction(CommonStrings.action_reply, CommonDrawables.ic_september_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CommonDrawables.ic_september_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CommonDrawables.ic_september_edit_outline)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_september_view_source)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CommonDrawables.ic_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CommonDrawables.ic_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CommonDrawables.ic_edit_outline)
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 EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CommonDrawables.ic_poll_end)
}

View file

@ -236,7 +236,7 @@ private fun MediaFileView(
) {
Icon(
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,
tint = MaterialTheme.colorScheme.background,
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.unit.dp
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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -52,8 +51,8 @@ fun MentionSuggestionsPickerView(
roomId: RoomId,
roomName: String?,
roomAvatarData: AvatarData?,
memberSuggestions: ImmutableList<RoomMemberSuggestion>,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
memberSuggestions: ImmutableList<MentionSuggestion>,
onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@ -63,8 +62,8 @@ fun MentionSuggestionsPickerView(
memberSuggestions,
key = { suggestion ->
when (suggestion) {
is RoomMemberSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value
is MentionSuggestion.Room -> "@room"
is MentionSuggestion.Member -> suggestion.roomMember.userId.value
}
}
) {
@ -85,18 +84,18 @@ fun MentionSuggestionsPickerView(
@Composable
private fun RoomMemberSuggestionItemView(
memberSuggestion: RoomMemberSuggestion,
memberSuggestion: MentionSuggestion,
roomId: String,
roomName: String?,
roomAvatar: AvatarData?,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val avatarSize = AvatarSize.TimelineRoom
val avatarData = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is RoomMemberSuggestion.Member -> AvatarData(
is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is MentionSuggestion.Member -> AvatarData(
memberSuggestion.roomMember.userId.value,
memberSuggestion.roomMember.displayName,
memberSuggestion.roomMember.avatarUrl,
@ -104,13 +103,13 @@ private fun RoomMemberSuggestionItemView(
)
}
val title = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName
is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
}
val subtitle = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value
is MentionSuggestion.Room -> "@room"
is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
}
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",
roomAvatarData = null,
memberSuggestions = persistentListOf(
RoomMemberSuggestion.Room,
RoomMemberSuggestion.Member(roomMember),
RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
MentionSuggestion.Room,
MentionSuggestion.Member(roomMember),
MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
),
onSuggestionSelected = {}
)

View file

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

View file

@ -19,9 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -33,12 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
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.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.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
@ -93,7 +94,6 @@ internal fun AttachmentsBottomSheet(
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun AttachmentSourcePickerMenu(
state: MessageComposerState,
@ -103,28 +103,33 @@ private fun AttachmentSourcePickerMenu(
modifier: Modifier = Modifier,
) {
Column(
modifier.padding(bottom = 32.dp)
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
modifier = modifier
.navigationBarsPadding()
.imePadding()
) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(CommonDrawables.ic_september_photo_video_library, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_image)),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(CommonDrawables.ic_september_attachment, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_attachment)),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(CommonDrawables.ic_september_take_photo_camera, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_take_photo_camera, )),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(CommonDrawables.ic_september_video_call, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_video_call)),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
style = ListItemStyle.Primary,
)
if (state.canShareLocation) {
ListItem(
@ -132,8 +137,9 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
onSendLocationClicked()
},
icon = { Icon(CommonDrawables.ic_september_location, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_location_pin) ),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
style = ListItemStyle.Primary,
)
}
if (state.canCreatePoll) {
@ -142,15 +148,17 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
onCreatePollClicked()
},
icon = { Icon(CommonDrawables.ic_compound_polls, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_compound_polls)),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
style = ListItemStyle.Primary,
)
}
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
icon = { Icon(CommonDrawables.ic_september_text_formatting, null) },
text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_text_formatting, null)),
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
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.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@ -41,4 +42,5 @@ sealed interface MessageComposerEvents {
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : 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.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter
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.FeatureFlags
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.RoomMember
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
@ -67,6 +68,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -87,7 +90,7 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
permissionsPresenterFactory: PermissionsPresenter.Factory
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<MessageComposerState> {
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) {
if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current
@ -184,8 +187,11 @@ class MessageComposerPresenter @Inject constructor(
return !roomIsDm && userCanSendAtRoom
}
suggestionSearchTrigger
.debounce(0.5.seconds)
// This will trigger a search immediately when `@` is typed
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 ->
memberSuggestions.clear()
val result = MentionSuggestionsProcessor.process(
@ -284,6 +290,20 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SuggestionReceived -> {
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,
attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) }
)
}
@ -307,15 +328,25 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState: RichTextEditorState,
) = launch {
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
richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal)
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 -> {
val eventId = capturedMode.eventId
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()
@ -323,6 +354,7 @@ class MessageComposerPresenter @Inject constructor(
capturedMode.eventId,
message.markdown,
message.html,
mentions
)
}
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.Stable
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.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@ -33,7 +35,8 @@ data class MessageComposerState(
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<RoomMemberSuggestion>,
val memberSuggestions: ImmutableList<MentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit,
) {
val hasFocus: Boolean = richTextEditorState.hasFocus

View file

@ -17,6 +17,8 @@
package io.element.android.features.messages.impl.messagecomposer
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.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@ -38,7 +40,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(),
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState(
richTextEditorState = composerState,
isFullScreen = isFullScreen,
@ -49,5 +51,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {},
)

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.textcomposer.TextComposer
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.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import kotlinx.coroutines.launch
@Composable
@ -46,6 +47,7 @@ internal fun MessageComposerView(
enableVoiceMessages: Boolean,
modifier: Modifier = Modifier,
) {
val view = LocalView.current
fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message))
}
@ -59,6 +61,7 @@ internal fun MessageComposerView(
}
fun onDismissTextFormatting() {
view.clearFocus()
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
}
@ -77,8 +80,8 @@ internal fun MessageComposerView(
}
}
val onVoiceRecordButtonEvent = { press: PressEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press))
}
val onSendVoiceMessage = {
@ -107,12 +110,13 @@ internal fun MessageComposerView(
onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
onVoiceRecorderEvent = onVoiceRecorderEvent,
onVoicePlayerEvent = onVoicePlayerEvent,
onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onSuggestionReceived = ::onSuggestionReceived,
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.tooling.preview.PreviewParameter
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.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.button.BackButton
@ -101,14 +102,14 @@ fun ReportMessageView(
OutlinedTextField(
value = state.reason,
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
placeholder = { Text(stringResource(CommonStrings.report_content_hint)) },
placeholder = { Text(stringResource(R.string.report_content_hint)) },
enabled = !isSending,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 90.dp)
)
Text(
text = stringResource(CommonStrings.report_content_explanation),
text = stringResource(R.string.report_content_explanation),
style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
@ -122,11 +123,11 @@ fun ReportMessageView(
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(CommonStrings.screen_report_content_block_user),
text = stringResource(R.string.screen_report_content_block_user),
style = ElementTheme.typography.fontBodyLgRegular,
)
Text(
text = stringResource(CommonStrings.screen_report_content_block_user_hint),
text = stringResource(R.string.screen_report_content_block_user_hint),
style = ElementTheme.typography.fontBodyMdRegular,
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.model.TimelineItem
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.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.encryption.BackupState
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.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.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -63,6 +67,8 @@ class TimelinePresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
) : Presenter<TimelineState> {
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) {
when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -136,12 +145,23 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) {
timeline
.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 ->
if (timelineItems.isEmpty()) {
paginateBackwards()
}
}
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.launchIn(this)
}
@ -150,6 +170,7 @@ class TimelinePresenter @Inject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value,
sessionState = sessionState,
eventSink = ::handleEvents

View file

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

View file

@ -16,9 +16,11 @@
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.TimelineItemGroupPosition
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.event.TimelineItemEventContent
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(
timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
showReadReceipts = false,
paginationState = MatrixTimeline.PaginationState(
isBackPaginating = false,
hasMoreToLoadBackwards = true,
beginningOfRoomReached = false,
),
highlightedEventId = null,
userHasPermissionToSendMessage = true,
hasNewItems = false,
@ -114,11 +121,12 @@ internal fun aTimelineItemEvent(
senderDisplayName: String = "Sender",
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
sendState: LocalEventSendState? = null,
inReplyTo: InReplyTo? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
): TimelineItem.Event {
return TimelineItem.Event(
id = UUID.randomUUID().toString(),
@ -128,6 +136,7 @@ internal fun aTimelineItemEvent(
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content,
reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
sentTime = "12:34",
isMine = isMine,
senderDisplayName = senderDisplayName,
@ -169,6 +178,12 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson
)
internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
return TimelineItemReadReceipts(
receipts = emptyList<ReadReceiptData>().toImmutableList(),
)
}
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
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.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
@ -82,6 +83,7 @@ import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
roomName: String?,
onUserDataClicked: (UserId) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
@ -90,6 +92,7 @@ fun TimelineView(
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
fun onReachedLoadMore() {
@ -124,6 +127,9 @@ fun TimelineView(
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
showReadReceipts = state.showReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true
&& state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value,
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked,
@ -133,6 +139,7 @@ fun TimelineView(
onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink,
@ -148,6 +155,11 @@ fun TimelineView(
}
}
}
if (state.paginationState.beginningOfRoomReached) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
}
TimelineScrollHelper(
@ -162,6 +174,8 @@ fun TimelineView(
@Composable
private fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
@ -172,6 +186,7 @@ private fun TimelineItemRow(
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@ -198,6 +213,8 @@ private fun TimelineItemRow(
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
@ -207,6 +224,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
@ -237,6 +255,8 @@ private fun TimelineItemRow(
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
@ -248,6 +268,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
@ -346,6 +367,7 @@ internal fun TimelineViewPreview(
) {
TimelineView(
state = aTimelineState(timelineItems),
roomName = null,
onMessageClicked = {},
onTimestampClicked = {},
onUserDataClicked = {},
@ -354,6 +376,7 @@ internal fun TimelineViewPreview(
onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {},
onSwipeToReply = {},
onReadReceiptClick = {},
)
}
}

View file

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

View file

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

View file

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

View file

@ -69,7 +69,7 @@ private fun TimelineItemReactionsView(
onToggleExpandClick: () -> Unit,
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.
val currentLayout = LocalLayoutDirection.current
val reactionsLayoutDirection = if (!isOutgoing) currentLayout
@ -95,7 +95,7 @@ private fun TimelineItemReactionsView(
},
addMoreButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_september_add_reaction),
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
onClick = onMoreReactionsClick,
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.components.virtual.TimelineEncryptedHistoryBannerView
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.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
@ -34,7 +35,7 @@ fun TimelineItemVirtualRow(
) {
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
@ -47,7 +48,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
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.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.messages.impl.R
@get:StringRes
val EmojibaseCategory.title: Int get() =
when(this){
EmojibaseCategory.People -> CommonStrings.emoji_picker_category_people
EmojibaseCategory.Nature -> CommonStrings.emoji_picker_category_nature
EmojibaseCategory.Foods -> CommonStrings.emoji_picker_category_foods
EmojibaseCategory.Activity -> CommonStrings.emoji_picker_category_activity
EmojibaseCategory.Places -> CommonStrings.emoji_picker_category_places
EmojibaseCategory.Objects -> CommonStrings.emoji_picker_category_objects
EmojibaseCategory.Symbols -> CommonStrings.emoji_picker_category_symbols
EmojibaseCategory.Flags -> CommonStrings.emoji_picker_category_flags
val EmojibaseCategory.title: Int
get() = when (this) {
EmojibaseCategory.People -> R.string.emoji_picker_category_people
EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
EmojibaseCategory.Places -> R.string.emoji_picker_category_places
EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
}
val EmojibaseCategory.icon: ImageVector
get() =
when(this){
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}
get() = when (this) {
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}

View file

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

View file

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

View file

@ -27,6 +27,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButtonDefaults
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.Modifier
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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
@Composable
fun TimelineItemVoiceView(
@ -90,7 +96,7 @@ fun TimelineItemVoiceView(
Spacer(Modifier.width(8.dp))
val context = LocalContext.current
WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause,
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
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
private fun ProgressButton() {
private fun ProgressButton(
displayImmediately: Boolean = false,
) {
var canDisplay by remember { mutableStateOf(displayImmediately) }
LaunchedEffect(Unit) {
delay(2000L)
canDisplay = true
}
CustomIconButton(
onClick = {},
enabled = false,
) {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(16.dp),
color = ElementTheme.colors.iconSecondary,
strokeWidth = 2.dp,
)
if (canDisplay) {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(16.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