Merge branch 'develop' into renovate/accompanist

This commit is contained in:
ganfra 2024-09-26 20:02:06 +02:00 committed by GitHub
commit c67b78b0a1
1401 changed files with 5847 additions and 3685 deletions

View file

@ -66,7 +66,7 @@ else
fi fi
if [[ -z ${INPUT_AUTHOR_EMAIL} ]]; then if [[ -z ${INPUT_AUTHOR_EMAIL} ]]; then
git config user.email "benoitm+elementbot@element.io" git config user.email "android@element.io"
else else
git config --local user.name "${INPUT_AUTHOR_EMAIL}" git config --local user.name "${INPUT_AUTHOR_EMAIL}"
fi fi

View file

@ -1,3 +1,39 @@
Changes in Element X v0.6.4 (2024-09-25)
========================================
### 🙌 Improvements
* Pinned messages : add pin icon in timeline for pinned events. by @ganfra in https://github.com/element-hq/element-x-android/pull/3500
* Include inviter in the notification for invitation by @bmarty in https://github.com/element-hq/element-x-android/pull/3503
### 🐛 Bugfixes
* Fix crash when session is deleted on another client by @bmarty in https://github.com/element-hq/element-x-android/pull/3515
* Fix pinned events banner reappearing when loading by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3519
* Fix various crashes by @bmarty in https://github.com/element-hq/element-x-android/pull/3533
* Perform the migration, even if the current version is not known. by @bmarty in https://github.com/element-hq/element-x-android/pull/3535
* timeline : makes sure to emit empty list if initial reset has no item. by @ganfra in https://github.com/element-hq/element-x-android/pull/3538
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3513
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3517
### Dependency upgrades
* Update dependency io.nlopez.compose.rules:detekt to v0.4.12 by @renovate in https://github.com/element-hq/element-x-android/pull/3436
* Update dependency com.posthog:posthog-android to v3.7.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3443
* Update dependency com.otaliastudios:transcoder to v0.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3440
* Update dependency org.maplibre.gl:android-sdk to v11.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3408
* Update dependencyAnalysis to v2.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3508
* Update dependency org.maplibre.gl:android-sdk-ktx-v7 to v3.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3507
* Update dependencyAnalysis to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3526
* Update dependency net.java.dev.jna:jna to v5.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3525
* Update dependency androidx.startup:startup-runtime to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3516
* dependencies : update rust sdk to 0.2.48 by @ganfra in https://github.com/element-hq/element-x-android/pull/3532
### Others
* Change ElementBot mail to android@element.io by @bmarty in https://github.com/element-hq/element-x-android/pull/3497
* Test RustMatrixClient and other classes in the matrix module by @bmarty in https://github.com/element-hq/element-x-android/pull/3501
* Pinned messages analytics by @ganfra in https://github.com/element-hq/element-x-android/pull/3523
* Remove ability to configure default log level by @bmarty in https://github.com/element-hq/element-x-android/pull/3531
Changes in Element X v0.6.3 (2024-09-19) Changes in Element X v0.6.3 (2024-09-19)
======================================== ========================================

View file

@ -250,10 +250,12 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
// Comment to not include firebase in the project if (ModulesConfig.pushProvidersConfig.includeFirebase) {
"gplayImplementation"(projects.libraries.pushproviders.firebase) "gplayImplementation"(projects.libraries.pushproviders.firebase)
// Comment to not include unified push in the project }
implementation(projects.libraries.pushproviders.unifiedpush) if (ModulesConfig.pushProvidersConfig.includeUnifiedPush) {
implementation(projects.libraries.pushproviders.unifiedpush)
}
implementation(libs.appyx.core) implementation(libs.appyx.core)
implementation(libs.androidx.splash) implementation(libs.androidx.splash)

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler import io.element.android.x.intent.SafeUriHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -64,6 +65,7 @@ class MainActivity : NodeActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this), LocalUriHandler provides SafeUriHandler(this),
LocalAnalyticsService provides appBindings.analyticsService(),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.tracing.TracingService import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
interface AppBindings { interface AppBindings {
@ -32,4 +33,6 @@ interface AppBindings {
fun migrationEntryPoint(): MigrationEntryPoint fun migrationEntryPoint(): MigrationEntryPoint
fun lockScreenEntryPoint(): LockScreenEntryPoint fun lockScreenEntryPoint(): LockScreenEntryPoint
fun analyticsService(): AnalyticsService
} }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйсці і абнавіць"</string>
</resources>

View file

@ -48,7 +48,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.11") detektPlugins("io.nlopez.compose.rules:detekt:0.4.12")
} }
// KtLint // KtLint

View file

@ -0,0 +1,2 @@
Main changes in this version: mainly bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -36,6 +35,7 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -104,14 +104,9 @@ private fun AnalyticsOptInHeader(
bold = true, bold = true,
tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK, tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
) )
ClickableText( ClickableLinkText(
text = text, annotatedString = text,
onClick = { onClick = { onClickTerms() },
text
.getStringAnnotations(LINK_TAG, it, it)
.firstOrNull()
?.let { _ -> onClickTerms() }
},
modifier = Modifier modifier = Modifier
.padding(8.dp), .padding(8.dp),
style = ElementTheme.typography.fontBodyMdRegular style = ElementTheme.typography.fontBodyMdRegular

View file

@ -5,6 +5,6 @@
<string name="screen_analytics_prompt_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string> <string name="screen_analytics_prompt_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"siin"</string> <string name="screen_analytics_prompt_read_terms_content_link">"siin"</string>
<string name="screen_analytics_prompt_settings">"Selle valiku saad igal ajal välja lülitada"</string> <string name="screen_analytics_prompt_settings">"Selle valiku saad igal ajal välja lülitada"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Me ei jaga andmeid kolmandate osapooltega"</string> <string name="screen_analytics_prompt_third_party_sharing">"Me ei jaga sinu andmeid kolmandate osapooltega"</string>
<string name="screen_analytics_prompt_title">"Aita parandada %1$s rakendust"</string> <string name="screen_analytics_prompt_title">"Aita parandada rakendust %1$s"</string>
</resources> </resources>

View file

@ -11,6 +11,6 @@ import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents { sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
CallScreenEvents data class OnWebViewError(val description: String?) : CallScreenEvents
} }

View file

@ -78,6 +78,8 @@ class CallScreenPresenter @AssistedInject constructor(
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) } val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) } val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) } var isJoinedCall by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag() val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark" val theme = if (ElementTheme.isLightTheme) "light" else "dark"
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -125,6 +127,8 @@ class CallScreenPresenter @AssistedInject constructor(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
interceptor.interceptedMessages interceptor.interceptedMessages
.onEach { .onEach {
// We are receiving messages from the WebView, consider that the application is loaded
ignoreWebViewError = true
// Relay message to Widget Driver // Relay message to Widget Driver
callWidgetDriver.value?.send(it) callWidgetDriver.value?.send(it)
@ -163,11 +167,18 @@ class CallScreenPresenter @AssistedInject constructor(
is CallScreenEvents.SetupMessageChannels -> { is CallScreenEvents.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor messageInterceptor.value = event.widgetMessageInterceptor
} }
is CallScreenEvents.OnWebViewError -> {
if (!ignoreWebViewError) {
webViewError = event.description.orEmpty()
}
// Else ignore the error, give a chance the Element Call to recover by itself.
}
} }
} }
return CallScreenState( return CallScreenState(
urlState = urlState.value, urlState = urlState.value,
webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
data class CallScreenState( data class CallScreenState(
val urlState: AsyncData<String>, val urlState: AsyncData<String>,
val webViewError: String?,
val userAgent: String, val userAgent: String,
val isInWidgetMode: Boolean, val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit, val eventSink: (CallScreenEvents) -> Unit,

View file

@ -16,17 +16,20 @@ open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
aCallScreenState(), aCallScreenState(),
aCallScreenState(urlState = AsyncData.Loading()), aCallScreenState(urlState = AsyncData.Loading()),
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
aCallScreenState(webViewError = "Error details from WebView"),
) )
} }
internal fun aCallScreenState( internal fun aCallScreenState(
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
webViewError: String? = null,
userAgent: String = "", userAgent: String = "",
isInWidgetMode: Boolean = false, isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {}, eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState { ): CallScreenState {
return CallScreenState( return CallScreenState(
urlState = urlState, urlState = urlState,
webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = eventSink, eventSink = eventSink,

View file

@ -85,35 +85,48 @@ internal fun CallScreenView(
BackHandler { BackHandler {
handleBack() handleBack()
} }
CallWebView( if (state.webViewError != null) {
modifier = Modifier ErrorDialog(
content = buildString {
append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
},
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
} else {
CallWebView(
modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.fillMaxSize(), .fillMaxSize(),
url = state.urlState, url = state.urlState,
userAgent = state.userAgent, userAgent = state.userAgent,
onPermissionsRequest = { request -> onPermissionsRequest = { request ->
val androidPermissions = mapWebkitPermissions(request.resources) val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) } val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback) requestPermissions(androidPermissions.toTypedArray(), callback)
}, },
onWebViewCreate = { webView -> onWebViewCreate = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView) val interceptor = WebViewWidgetMessageInterceptor(
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) webView = webView,
val pipController = WebViewPipController(webView) onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) )
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
}
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure ->
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
is AsyncData.Success -> Unit
} }
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure ->
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
is AsyncData.Success -> Unit
} }
} }
} }

View file

@ -281,7 +281,11 @@ class ElementCallActivity :
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun enterPipMode(): Boolean { override fun enterPipMode(): Boolean {
return enterPictureInPictureMode(getPictureInPictureParams()) return if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
enterPictureInPictureMode(getPictureInPictureParams())
} else {
false
}
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)

View file

@ -8,16 +8,23 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.http.SslError
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import timber.log.Timber
class WebViewWidgetMessageInterceptor( class WebViewWidgetMessageInterceptor(
private val webView: WebView, private val webView: WebView,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor { ) : WidgetMessageInterceptor {
companion object { companion object {
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this // We call both the WebMessageListener and the JavascriptInterface objects in JS with this
@ -45,16 +52,35 @@ class WebViewWidgetMessageInterceptor(
if (message.data.response && message.data.api == "toWidget" if (message.data.response && message.data.api == "toWidget"
|| !message.data.response && message.data.api == "fromWidget") { || !message.data.response && message.data.api == "fromWidget") {
let json = JSON.stringify(event.data) let json = JSON.stringify(event.data)
${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } } ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG }}
$LISTENER_NAME.postMessage(json); $LISTENER_NAME.postMessage(json);
} else { } else {
${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } } ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG }}
} }
}); });
""".trimIndent(), """.trimIndent(),
null null
) )
} }
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
onError(error?.description?.toString())
super.onReceivedError(view, request, error)
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}")
onError(errorResponse?.statusCode.toString())
super.onReceivedHttpError(view, request, errorResponse)
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
Timber.e("onReceivedSslError error: ${error?.primaryError}")
onError(error?.primaryError?.toString())
super.onReceivedSslError(view, handler, error)
}
} }
// Create a WebMessageListener, which will receive messages from the WebView and reply to them // Create a WebMessageListener, which will receive messages from the WebView and reply to them

View file

@ -71,6 +71,7 @@ class CallScreenPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse() assertThat(initialState.isInWidgetMode).isFalse()
analyticsLambda.assertions().isNeverCalled() analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
@ -270,6 +271,48 @@ class CallScreenPresenterTest {
assert(stopSyncLambda).isCalledOnce() assert(stopSyncLambda).isCalledOnce()
} }
@Test
fun `present - error from WebView are updating the state`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isEqualTo("A Webview error")
}
}
@Test
fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
val messageInterceptor = FakeWidgetMessageInterceptor()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// Emit a message
messageInterceptor.givenInterceptedMessage("A message")
// WebView emits an error, but it will be ignored
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
}
}
private fun TestScope.createCallScreenPresenter( private fun TestScope.createCallScreenPresenter(
callType: CallType, callType: CallType,
navigator: CallScreenNavigator = FakeCallScreenNavigator(), navigator: CallScreenNavigator = FakeCallScreenNavigator(),

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Potvrďte prosím, že chcete svůj účet deaktivovat. Tuto akci nelze vrátit zpět."</string>
<string name="screen_deactivate_account_delete_all_messages">"Smazat všechny mé zprávy"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Upozornění: Budoucí uživatelé mohou vidět neúplné konverzace."</string>
<string name="screen_deactivate_account_description">"Deaktivace vašeho účtu je %1$s, což způsobí:"</string>
<string name="screen_deactivate_account_description_bold_part">"nezvratná"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s váš účet (nemůžete se znovu přihlásit a vaše ID nelze znovu použít)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Trvale zakázat"</string>
<string name="screen_deactivate_account_list_item_2">"Odebere vás ze všech chatovacích místností."</string>
<string name="screen_deactivate_account_list_item_3">"Odstraní informace o vašem účtu z našeho serveru identit."</string>
<string name="screen_deactivate_account_list_item_4">"Vaše zprávy budou stále viditelné registrovaným uživatelům, ale nebudou dostupné novým ani neregistrovaným uživatelům, pokud se rozhodnete je smazat."</string>
<string name="screen_deactivate_account_title">"Deaktivovat účet"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Παρακαλώ επιβεβαίωσε ότι θες να απενεργοποιήσεις τον λογαριασμό σου. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."</string>
<string name="screen_deactivate_account_delete_all_messages">"Διαγραφή όλων των μηνυμάτων μου"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Προειδοποίηση: Οι μελλοντικοί χρήστες ενδέχεται να βλέπουν ελλιπείς συνομιλίες."</string>
<string name="screen_deactivate_account_description">"Η απενεργοποίηση του λογαριασμού σας είναι %1$s, θα:"</string>
<string name="screen_deactivate_account_description_bold_part">"μη αναστρέψιμο"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Μόνιμη απενεργοποίηση"</string>
<string name="screen_deactivate_account_list_item_2">"Σε αφαιρέσει από όλα τα δωμάτια συνομιλίας."</string>
<string name="screen_deactivate_account_list_item_3">"Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας."</string>
<string name="screen_deactivate_account_list_item_4">"Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις."</string>
<string name="screen_deactivate_account_title">"Απενεργοποίηση λογαριασμού"</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="screen_deactivate_account_confirmation_dialog_content">"Palun kinnita uuesti, et soovid eemaldada oma konto kasutusest"</string>
<string name="screen_deactivate_account_delete_all_messages">"Kustuta kõik minu sõnumid"</string>
<string name="screen_deactivate_account_title">"Eemalda konto kasutusest"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Veuillez confirmer que vous souhaitez désactiver votre compte. Cette action ne peut pas être annulée."</string>
<string name="screen_deactivate_account_delete_all_messages">"Supprimer tous mes messages"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Attention : les futurs utilisateurs pourraient voir des conversations incomplètes."</string>
<string name="screen_deactivate_account_description">"La désactivation de votre compte est %1$s, cela va:"</string>
<string name="screen_deactivate_account_description_bold_part">"irréversible"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s votre compte (vous ne pourrez plus vous reconnecter et votre identifiant ne pourra pas être réutilisé)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Désactiver définitivement"</string>
<string name="screen_deactivate_account_list_item_2">"Vous retirer de tous les salons et toutes les discussions."</string>
<string name="screen_deactivate_account_list_item_3">"Supprimer les informations de votre compte du serveur didentité."</string>
<string name="screen_deactivate_account_list_item_4">"Rendre vos messages invisibles aux futurs membres des salons si vous choisissez de les supprimer. Vos messages seront toujours visibles pour les utilisateurs qui les ont déjà récupérés."</string>
<string name="screen_deactivate_account_title">"Désactiver votre compte"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Erősítse meg, hogy deaktiválja a fiókját. Ez a művelet nem vonható vissza."</string>
<string name="screen_deactivate_account_delete_all_messages">"Összes saját üzenet törlése"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Figyelmeztetés: A jövőbeli felhasználók hiányos beszélgetéseket láthatnak."</string>
<string name="screen_deactivate_account_description">"A fiók deaktiválása %1$s, a következőket okozza:"</string>
<string name="screen_deactivate_account_description_bold_part">"visszafordíthatatlan"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Véglegesen letiltja"</string>
<string name="screen_deactivate_account_list_item_2">"Eltávolításra kerül az összes csevegőszobából."</string>
<string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai a személyazonosító kiszolgálónkról."</string>
<string name="screen_deactivate_account_list_item_4">"Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."</string>
<string name="screen_deactivate_account_title">"Fiók deaktiválása"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Подтвердите, что вы хотите деактивировать свою учетную запись. Это действие не может быть отменено."</string>
<string name="screen_deactivate_account_delete_all_messages">"Удалить все мои сообщения"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Предупреждение: будущие пользователи могут увидеть незавершенные разговоры."</string>
<string name="screen_deactivate_account_description">"Деактивация вашей учетной записи %1$s означает следующее:"</string>
<string name="screen_deactivate_account_description_bold_part">"необратимый"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s вашей учетной записи (вы не можете войти в систему снова, и ваш ID не может быть использован повторно)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Отключить навсегда"</string>
<string name="screen_deactivate_account_list_item_2">"Удалите вас из всех чатов."</string>
<string name="screen_deactivate_account_list_item_3">"Удалите данные своей учетной записи с нашего сервера идентификации."</string>
<string name="screen_deactivate_account_list_item_4">"Ваши сообщения по-прежнему будут видны зарегистрированным пользователям, но не будут доступны новым или незарегистрированным пользователям, если вы решите удалить их."</string>
<string name="screen_deactivate_account_title">"Отключить учётную запись"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Prosím potvrďte, že chcete deaktivovať svoj účet. Túto akciu nie je možné vrátiť späť."</string>
<string name="screen_deactivate_account_delete_all_messages">"Vymazať všetky moje správy"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Upozornenie: Budúcim používateľom sa môžu zobraziť neúplné konverzácie."</string>
<string name="screen_deactivate_account_description">"Deaktivácia vášho účtu znamená %1$s, že:"</string>
<string name="screen_deactivate_account_description_bold_part">"nezvratný"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Natrvalo zakázať"</string>
<string name="screen_deactivate_account_list_item_4">"Vaše správy budú stále viditeľné pre registrovaných používateľov, ale nebudú dostupné pre nových alebo neregistrovaných používateľov, ak sa ich rozhodnete odstrániť."</string>
<string name="screen_deactivate_account_title">"Deaktivovať účet"</string>
</resources>

View file

@ -18,10 +18,10 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.impl.LockScreenConfig import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.R import io.element.android.features.lockscreen.impl.R

View file

@ -14,7 +14,7 @@
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Säästa aega ja kasuta alati %1$s rakenduse lukustuse eemaldamiseks"</string> <string name="screen_app_lock_setup_biometric_unlock_subtitle">"Säästa aega ja kasuta alati %1$s rakenduse lukustuse eemaldamiseks"</string>
<string name="screen_app_lock_setup_choose_pin">"Vali PIN-kood"</string> <string name="screen_app_lock_setup_choose_pin">"Vali PIN-kood"</string>
<string name="screen_app_lock_setup_confirm_pin">"Korda PIN-koodi"</string> <string name="screen_app_lock_setup_confirm_pin">"Korda PIN-koodi"</string>
<string name="screen_app_lock_setup_pin_context">"Lisamaks oma %1$s vestlustele turvalisust ja privaatsust, lukusta oma nutiseade. <string name="screen_app_lock_setup_pin_context">"Lisamaks oma %1$s rakenduse vestlustele turvalisust ja privaatsust, lukusta oma nutiseade.
Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvakaalutlustel logitakse sind rakendusest välja."</string> Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvakaalutlustel logitakse sind rakendusest välja."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada"</string> <string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada"</string>
@ -31,7 +31,7 @@ Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvaka
<item quantity="one">"Vale PIN-kood. Saad proovida veel %1$d korra"</item> <item quantity="one">"Vale PIN-kood. Saad proovida veel %1$d korra"</item>
<item quantity="other">"Vale PIN-kood. Saad proovida veel %1$d korda"</item> <item quantity="other">"Vale PIN-kood. Saad proovida veel %1$d korda"</item>
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Kasuta biomeetrilist tuvastust"</string> <string name="screen_app_lock_use_biometric_android">"Kasuta biomeetriat"</string>
<string name="screen_app_lock_use_pin_android">"Kasuta PIN-koodi"</string> <string name="screen_app_lock_use_pin_android">"Kasuta PIN-koodi"</string>
<string name="screen_signout_in_progress_dialog_content">"Logime välja…"</string> <string name="screen_signout_in_progress_dialog_content">"Logime välja…"</string>
</resources> </resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Вы можаце падключыцца толькі да існуючага сервера, які падтрымлівае sliding sync. Адміністратару хатняга сервера запатрабуецца наладзіць яго. %1$s"</string> <string name="screen_change_server_form_notice">"Вы можаце падключыцца толькі да існуючага сервера, які падтрымлівае sliding sync. Адміністратару хатняга сервера запатрабуецца наладзіць яго. %1$s"</string>
<string name="screen_change_server_subtitle">"Які адрас вашага сервера?"</string> <string name="screen_change_server_subtitle">"Які адрас вашага сервера?"</string>
<string name="screen_change_server_title">"Выберыце свой сервер"</string> <string name="screen_change_server_title">"Выберыце свой сервер"</string>
<string name="screen_create_account_title">"Стварыць уліковы запіс"</string>
<string name="screen_login_error_deactivated_account">"Гэты ўліковы запіс быў дэактываваны."</string> <string name="screen_login_error_deactivated_account">"Гэты ўліковы запіс быў дэактываваны."</string>
<string name="screen_login_error_invalid_credentials">"Няправільнае імя карыстальніка і/або пароль"</string> <string name="screen_login_error_invalid_credentials">"Няправільнае імя карыстальніка і/або пароль"</string>
<string name="screen_login_error_invalid_user_id">"Гэта несапраўдны ідэнтыфікатар карыстальніка. Чаканы фармат: @user:homeserver.org"</string> <string name="screen_login_error_invalid_user_id">"Гэта несапраўдны ідэнтыфікатар карыстальніка. Чаканы фармат: @user:homeserver.org"</string>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Můžete se připojit pouze k serveru, který podporuje klouzavou synchronizaci. Správce vašeho domovského serveru jej bude muset nakonfigurovat. %1$s"</string> <string name="screen_change_server_form_notice">"Můžete se připojit pouze k serveru, který podporuje klouzavou synchronizaci. Správce vašeho domovského serveru jej bude muset nakonfigurovat. %1$s"</string>
<string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string> <string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string>
<string name="screen_change_server_title">"Vyberte váš server"</string> <string name="screen_change_server_title">"Vyberte váš server"</string>
<string name="screen_create_account_title">"Vytvořit účet"</string>
<string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string> <string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string> <string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>

View file

@ -5,9 +5,9 @@
<string name="screen_account_provider_form_notice">"Sisesta otsingusõna või domeeni nimi."</string> <string name="screen_account_provider_form_notice">"Sisesta otsingusõna või domeeni nimi."</string>
<string name="screen_account_provider_form_subtitle">"Otsi äriühingut, kogukonda või võrgus leiduvat Matrixi serverit."</string> <string name="screen_account_provider_form_subtitle">"Otsi äriühingut, kogukonda või võrgus leiduvat Matrixi serverit."</string>
<string name="screen_account_provider_form_title">"Leia teenusepakkuja"</string> <string name="screen_account_provider_form_title">"Leia teenusepakkuja"</string>
<string name="screen_account_provider_signin_subtitle">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postitenuse pakkujat."</string> <string name="screen_account_provider_signin_subtitle">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat."</string>
<string name="screen_account_provider_signin_title">"Sa oled sisse logimas %s teenusesse"</string> <string name="screen_account_provider_signin_title">"Sa oled sisse logimas %s teenusesse"</string>
<string name="screen_account_provider_signup_subtitle">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postitenuse pakkujat."</string> <string name="screen_account_provider_signup_subtitle">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat."</string>
<string name="screen_account_provider_signup_title">"Sa oled loomas kasutajakontot %s teenuses"</string> <string name="screen_account_provider_signup_title">"Sa oled loomas kasutajakontot %s teenuses"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org on suur ja tasuta koduserver Matrixi võrgus, mis on mõeldud turvalise ja hajutatud suhtluse jaoks. Selle serveri halduse eest vastutab Matrix.org Foundation."</string> <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org on suur ja tasuta koduserver Matrixi võrgus, mis on mõeldud turvalise ja hajutatud suhtluse jaoks. Selle serveri halduse eest vastutab Matrix.org Foundation."</string>
<string name="screen_change_account_provider_other">"Muu teenusepakkuja"</string> <string name="screen_change_account_provider_other">"Muu teenusepakkuja"</string>
@ -16,7 +16,7 @@
<string name="screen_change_server_error_invalid_homeserver">"Me ei suutnud luuaühendust selle koduserveriga. Palun kontrolli, kas koduserveri aadress on õige. Kui aadress on õige, siis täiendavat teavet oskab sulle anda koduserveri haldaja."</string> <string name="screen_change_server_error_invalid_homeserver">"Me ei suutnud luuaühendust selle koduserveriga. Palun kontrolli, kas koduserveri aadress on õige. Kui aadress on õige, siis täiendavat teavet oskab sulle anda koduserveri haldaja."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync režiim pole saadaval vea tõttu well-known failis: <string name="screen_change_server_error_invalid_well_known">"Sliding sync režiim pole saadaval vea tõttu well-known failis:
%1$s"</string> %1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"See koduserver hetkel ei toeta Sliding sync režiimi"</string> <string name="screen_change_server_error_no_sliding_sync_message">"See koduserver hetkel ei toeta Sliding sync režiimi"</string>
<string name="screen_change_server_form_header">"Koduserveri url"</string> <string name="screen_change_server_form_header">"Koduserveri url"</string>
<string name="screen_change_server_form_notice">"Sa saad luua ühendust vaid olemasoleva serveriga, mis toetab Sliding sync režiimi. Sinu koduserveri haldur peaks selle seadistama. %1$s"</string> <string name="screen_change_server_form_notice">"Sa saad luua ühendust vaid olemasoleva serveriga, mis toetab Sliding sync režiimi. Sinu koduserveri haldur peaks selle seadistama. %1$s"</string>
<string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string> <string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string>
@ -44,7 +44,7 @@
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Teine seade pole sisselogitud"</string> <string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Teine seade pole sisselogitud"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Sisselogimine katkestati teises seadmes."</string> <string name="screen_qr_code_login_error_cancelled_subtitle">"Sisselogimine katkestati teises seadmes."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Sisselogimispäring on tühistatud"</string> <string name="screen_qr_code_login_error_cancelled_title">"Sisselogimispäring on tühistatud"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Sisselogimisest on teise seadmes keeldutud."</string> <string name="screen_qr_code_login_error_declined_subtitle">"Sisselogimisest on teises seadmes keeldutud."</string>
<string name="screen_qr_code_login_error_declined_title">"Sisselogimisest on keeldutud"</string> <string name="screen_qr_code_login_error_declined_title">"Sisselogimisest on keeldutud"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Sisselogimine aegus. Palun proovi uuesti."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Sisselogimine aegus. Palun proovi uuesti."</string>
<string name="screen_qr_code_login_error_expired_title">"Sisselogimine jäi etteantud aja jooksul tegemata"</string> <string name="screen_qr_code_login_error_expired_title">"Sisselogimine jäi etteantud aja jooksul tegemata"</string>
@ -76,7 +76,7 @@ Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega."</strin
<string name="screen_server_confirmation_change_server">"Muuda teenusepakujat"</string> <string name="screen_server_confirmation_change_server">"Muuda teenusepakujat"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Privaatne server Elemendi töötajate jaoks."</string> <string name="screen_server_confirmation_message_login_element_dot_io">"Privaatne server Elemendi töötajate jaoks."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix on avatud võrk turvalise ja hajutatud suhtluse jaoks."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix on avatud võrk turvalise ja hajutatud suhtluse jaoks."</string>
<string name="screen_server_confirmation_message_register">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postitenuse pakkujat."</string> <string name="screen_server_confirmation_message_register">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat."</string>
<string name="screen_server_confirmation_title_login">"Sa oled sisselogimas koduserverisse %1$s"</string> <string name="screen_server_confirmation_title_login">"Sa oled sisselogimas koduserverisse %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sa oled loomas kasutajakontot koduserveris %1$s"</string> <string name="screen_server_confirmation_title_register">"Sa oled loomas kasutajakontot koduserveris %1$s"</string>
</resources> </resources>

View file

@ -54,7 +54,7 @@ dependencies {
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
@ -77,6 +78,7 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -104,6 +106,7 @@ class MessagesPresenter @AssistedInject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val timelineController: TimelineController, private val timelineController: TimelineController,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default) private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
@ -285,6 +288,12 @@ class MessagesPresenter @AssistedInject constructor(
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) { private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return if (targetEvent.eventId == null) return
analyticsService.capture(
PinUnpinAction(
from = PinUnpinAction.From.Timeline,
kind = PinUnpinAction.Kind.Pin,
)
)
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
pinEvent(targetEvent.eventId) pinEvent(targetEvent.eventId)
.onFailure { .onFailure {
@ -296,6 +305,12 @@ class MessagesPresenter @AssistedInject constructor(
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return if (targetEvent.eventId == null) return
analyticsService.capture(
PinUnpinAction(
from = PinUnpinAction.From.Timeline,
kind = PinUnpinAction.Kind.Unpin,
)
)
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
unpinEvent(targetEvent.eventId) unpinEvent(targetEvent.eventId)
.onFailure { .onFailure {

View file

@ -25,11 +25,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -356,7 +356,7 @@ private fun EmojiReactionsRow(
.clickable( .clickable(
enabled = true, enabled = true,
onClick = onCustomReactionClick, onClick = onCustomReactionClick,
indication = rememberRipple(bounded = false, radius = emojiRippleRadius), indication = ripple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
) )
@ -433,7 +433,7 @@ private fun EmojiButton(
.clickable( .clickable(
enabled = true, enabled = true,
onClick = { onClick(emoji) }, onClick = { onClick(emoji) },
indication = rememberRipple(bounded = false, radius = emojiRippleRadius), indication = ripple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
) )

View file

@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -106,7 +105,7 @@ class PinnedMessagesBannerPresenter @Inject constructor(
} }
} }
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Composable @Composable
private fun PinnedMessagesBannerItemsEffect( private fun PinnedMessagesBannerItemsEffect(
onItemsChange: (AsyncData<ImmutableList<PinnedMessagesBannerItem>>) -> Unit, onItemsChange: (AsyncData<ImmutableList<PinnedMessagesBannerItem>>) -> Unit,

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -51,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndica
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@Composable @Composable
fun PinnedMessagesBannerView( fun PinnedMessagesBannerView(
@ -79,6 +82,7 @@ private fun PinnedMessagesBannerRow(
onViewAllClick: () -> Unit, onViewAllClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val analyticsService = LocalAnalyticsService.current
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
Row( Row(
modifier = modifier modifier = modifier
@ -88,6 +92,7 @@ private fun PinnedMessagesBannerRow(
.heightIn(min = 64.dp) .heightIn(min = 64.dp)
.clickable { .clickable {
if (state is PinnedMessagesBannerState.Loaded) { if (state is PinnedMessagesBannerState.Loaded) {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerClick)
onClick(state.currentPinnedMessage.eventId) onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
} }
@ -112,7 +117,13 @@ private fun PinnedMessagesBannerRow(
message = state.formattedMessage(), message = state.formattedMessage(),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
ViewAllButton(state, onViewAllClick) ViewAllButton(
state = state,
onViewAllClick = {
onViewAllClick()
analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerViewAllButton)
},
)
} }
} }

View file

@ -20,6 +20,8 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -39,9 +41,10 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -58,6 +61,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory, actionListPresenterFactory: ActionListPresenter.Factory,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<PinnedMessagesListState> { ) : Presenter<PinnedMessagesListState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -82,6 +86,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false, userHasPermissionToSendReaction = false,
isCallOngoing = false, isCallOngoing = false,
// don't compute this value or the pin icon will be shown
pinnedEventIds = emptyList()
) )
} }
@ -128,6 +134,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> { TimelineItemAction.ViewInTimeline -> {
targetEvent.eventId?.let { eventId -> targetEvent.eventId?.let { eventId ->
analyticsService.captureInteraction(Interaction.Name.PinnedMessageListViewTimeline)
navigator.onViewInTimelineClick(eventId) navigator.onViewInTimelineClick(eventId)
} }
} }
@ -137,6 +144,12 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return if (targetEvent.eventId == null) return
analyticsService.capture(
PinUnpinAction(
from = PinUnpinAction.From.MessagePinningList,
kind = PinUnpinAction.Kind.Unpin,
)
)
timelineProvider.invokeOnTimeline { timelineProvider.invokeOnTimeline {
unpinEvent(targetEvent.eventId) unpinEvent(targetEvent.eventId)
.onFailure { .onFailure {
@ -159,7 +172,6 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
} }
} }
@OptIn(FlowPreview::class)
@Composable @Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) { private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange) val updatedOnItemsChange by rememberUpdatedState(onItemsChange)

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
@ -44,6 +45,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@Composable @Composable
fun PinnedMessagesListView( fun PinnedMessagesListView(
@ -57,7 +60,14 @@ fun PinnedMessagesListView(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
PinnedMessagesListTopBar(state, onBackClick) val analyticsService = LocalAnalyticsService.current
PinnedMessagesListTopBar(
state = state,
onBackClick = {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerCloseListButton)
onBackClick()
}
)
}, },
content = { padding -> content = { padding ->
PinnedMessagesListContent( PinnedMessagesListContent(
@ -67,8 +77,8 @@ fun PinnedMessagesListView(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onErrorDismiss = onBackClick, onErrorDismiss = onBackClick,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) )
} }
) )

View file

@ -233,6 +233,7 @@ class TimelinePresenter @AssistedInject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(), isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
) )
} }
} }

View file

@ -67,4 +67,5 @@ data class TimelineRoomInfo(
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean, val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean, val isCallOngoing: Boolean,
val pinnedEventIds: List<EventId>
) )

View file

@ -240,10 +240,12 @@ internal fun aTimelineRoomInfo(
name: String = "Room name", name: String = "Room name",
isDm: Boolean = false, isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true, userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(),
) = TimelineRoomInfo( ) = TimelineRoomInfo(
isDm = isDm, isDm = isDm,
name = name, name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true, userHasPermissionToSendReaction = true,
isCallOngoing = false, isCallOngoing = false,
pinnedEventIds = pinnedEventIds,
) )

View file

@ -128,8 +128,8 @@ fun TimelineView(
Box(modifier) { Box(modifier) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
@ -145,6 +145,7 @@ fun TimelineView(
key = { timelineItem -> timelineItem.identifier() }, key = { timelineItem -> timelineItem.identifier() },
) { timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
modifier = Modifier.animateItem(),
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = state.renderReadReceipts, renderReadReceipts = state.renderReadReceipts,
@ -269,8 +270,8 @@ private fun BoxScope.TimelineScrollHelper(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() }, onClick = { jumpToBottom() },
) )
} }
@ -297,8 +298,8 @@ private fun JumpToBottomButton(
) { ) {
Icon( Icon(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.rotate(90f), .rotate(90f),
imageVector = CompoundIcons.ArrowRight(), imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
) )
@ -312,12 +313,18 @@ internal fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreview { ) = ElementPreview {
val timelineItems = aTimelineItemList(content) val timelineItems = aTimelineItemList(content)
val timelineEvents = timelineItems.filterIsInstance<TimelineItem.Event>()
val lastEventIdFromMe = timelineEvents.firstOrNull { it.isMine }?.eventId
val lastEventIdFromOther = timelineEvents.firstOrNull { !it.isMine }?.eventId
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) { ) {
TimelineView( TimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineRoomInfo = aTimelineRoomInfo(
pinnedEventIds = listOfNotNull(lastEventIdFromMe, lastEventIdFromOther)
),
focusedEventIndex = 0, focusedEventIndex = 0,
), ),
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),

View file

@ -11,13 +11,12 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,6 +39,7 @@ import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -49,11 +49,11 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
internal val BUBBLE_INCOMING_OFFSET = 16.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val avatarRadius = AvatarSize.TimelineSender.dp / 2
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now. // Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -93,14 +93,6 @@ fun MessageEventBubble(
} }
} }
fun Modifier.offsetForItem(): Modifier {
return when {
state.isMine -> this
state.timelineRoomInfo.isDm -> this
else -> offset(x = BUBBLE_INCOMING_OFFSET)
}
}
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when { val backgroundBubbleColor = when {
state.isMine -> ElementTheme.colors.messageFromMeBackground state.isMine -> ElementTheme.colors.messageFromMeBackground
@ -109,11 +101,8 @@ fun MessageEventBubble(
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
Box( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxWidth(BUBBLE_WIDTH_RATIO)
.padding(start = avatarRadius, end = 16.dp)
.offsetForItem()
.graphicsLayer { .graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
@ -138,12 +127,15 @@ fun MessageEventBubble(
Surface( Surface(
modifier = Modifier modifier = Modifier
.testTag(TestTags.messageBubble) .testTag(TestTags.messageBubble)
.widthIn(min = 80.dp) .widthIn(
min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO).toInt().toDp()
)
.clip(bubbleShape) .clip(bubbleShape)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
indication = rememberRipple(), indication = ripple(),
interactionSource = interactionSource interactionSource = interactionSource
), ),
color = backgroundBubbleColor, color = backgroundBubbleColor,

View file

@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -48,7 +48,7 @@ fun MessageStateEventContainer(
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
indication = rememberRipple(), indication = ripple(),
interactionSource = interactionSource interactionSource = interactionSource
), ),
color = backgroundColor, color = backgroundColor,

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -100,6 +101,8 @@ val NEGATIVE_MARGIN_FOR_BUBBLE = (-8).dp
// Width of the transparent border around the sender avatar // Width of the transparent border around the sender avatar
val SENDER_AVATAR_BORDER_WIDTH = 3.dp val SENDER_AVATAR_BORDER_WIDTH = 3.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -277,6 +280,7 @@ private fun TimelineItemEventRowContent(
sender, sender,
message, message,
reactions, reactions,
pinIcon,
) = createRefs() ) = createRefs()
// Sender // Sender
@ -311,7 +315,12 @@ private fun TimelineItemEventRowContent(
modifier = Modifier modifier = Modifier
.constrainAs(message) { .constrainAs(message) {
top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE) top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
this.linkStartOrEnd(event) if (event.isMine) {
end.linkTo(parent.end, margin = 16.dp)
} else {
val startMargin = if (timelineRoomInfo.isDm) 16.dp else 16.dp + BUBBLE_INCOMING_OFFSET
start.linkTo(parent.start, margin = startMargin)
}
}, },
state = bubbleState, state = bubbleState,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -327,6 +336,27 @@ private fun TimelineItemEventRowContent(
) )
} }
// Pin icon
val isEventPinned = timelineRoomInfo.pinnedEventIds.contains(event.eventId)
if (isEventPinned) {
Icon(
imageVector = CompoundIcons.PinSolid(),
contentDescription = stringResource(CommonStrings.common_pinned),
tint = ElementTheme.colors.iconTertiary,
modifier = Modifier
.padding(1.dp)
.size(16.dp)
.constrainAs(pinIcon) {
top.linkTo(message.top)
if (event.isMine) {
end.linkTo(message.start, margin = 8.dp)
} else {
start.linkTo(message.end, margin = 8.dp)
}
}
)
}
// Reactions // Reactions
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView( TimelineItemReactionsView(
@ -364,7 +394,7 @@ private fun MessageSenderInformation(
senderAvatar: AvatarData, senderAvatar: AvatarData,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide(senderAvatar.id)
Row(modifier = modifier) { Row(modifier = modifier) {
Avatar(senderAvatar) Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View file

@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -61,7 +61,7 @@ fun EmojiItem(
.clickable( .clickable(
enabled = true, enabled = true,
onClick = { onSelectEmoji(item) }, onClick = { onSelectEmoji(item) },
indication = rememberRipple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
.clearAndSetSemantics { .clearAndSetSemantics {

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.voicemessages.VoiceMessageExcep
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.utils.time.formatShort import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -126,8 +127,8 @@ class VoiceMessagePresenter @AssistedInject constructor(
it it
}, },
) { ) {
player.prepare().apply { player.prepare().flatMap {
player.play() runCatching { player.play() }
} }
} }
} }

View file

@ -42,8 +42,8 @@
<item quantity="other">"%1$d jututoa muudatust"</item> <item quantity="other">"%1$d jututoa muudatust"</item>
</plurals> </plurals>
<plurals name="screen_room_typing_many_members"> <plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s ja veel %3$d huviline"</item> <item quantity="one">"%1$s, %2$s ja veel %3$d osaleja"</item>
<item quantity="other">"%1$s, %2$s ja veel %3$d huvilist"</item> <item quantity="other">"%1$s, %2$s ja veel %3$d osalejat"</item>
</plurals> </plurals>
<plurals name="screen_room_typing_notification"> <plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s kirjutab"</item> <item quantity="one">"%1$s kirjutab"</item>

View file

@ -13,6 +13,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -896,6 +897,7 @@ class MessagesPresenterTest {
fun `present - handle action pin`() = runTest { fun `present - handle action pin`() = runTest {
val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) } val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
val analyticsService = FakeAnalyticsService()
val timeline = FakeTimeline() val timeline = FakeTimeline()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
liveTimeline = timeline, liveTimeline = timeline,
@ -906,7 +908,7 @@ class MessagesPresenterTest {
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -923,6 +925,10 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent))
assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
assertThat(awaitItem().snackbarMessage).isNotNull() assertThat(awaitItem().snackbarMessage).isNotNull()
assertThat(analyticsService.capturedEvents).containsExactly(
PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline),
PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline)
)
} }
} }
@ -931,6 +937,7 @@ class MessagesPresenterTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) } val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
val timeline = FakeTimeline() val timeline = FakeTimeline()
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
liveTimeline = timeline, liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
@ -940,7 +947,7 @@ class MessagesPresenterTest {
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -957,6 +964,10 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent))
assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
assertThat(awaitItem().snackbarMessage).isNotNull() assertThat(awaitItem().snackbarMessage).isNotNull()
assertThat(analyticsService.capturedEvents).containsExactly(
PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline),
PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline)
)
} }
} }
@ -1074,6 +1085,7 @@ class MessagesPresenterTest {
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
timelineController = TimelineController(matrixRoom), timelineController = TimelineController(matrixRoom),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
analyticsService = analyticsService,
) )
} }
} }

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.pinned.list package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
@ -30,6 +31,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -142,7 +145,7 @@ class PinnedMessagesListPresenterTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) } val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) }
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
@ -151,7 +154,7 @@ class PinnedMessagesListPresenterTest {
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
} }
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true, analyticsService = analyticsService)
presenter.test { presenter.test {
skipItems(3) skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled val filledState = awaitItem() as PinnedMessagesListState.Filled
@ -174,6 +177,11 @@ class PinnedMessagesListPresenterTest {
assert(failureUnpinEventLambda) assert(failureUnpinEventLambda)
.isCalledOnce() .isCalledOnce()
.with(value(AN_EVENT_ID)) .with(value(AN_EVENT_ID))
assertThat(analyticsService.capturedEvents).containsExactly(
PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList),
PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList)
)
} }
} }
@ -286,6 +294,7 @@ class PinnedMessagesListPresenterTest {
room: MatrixRoom = FakeMatrixRoom(), room: MatrixRoom = FakeMatrixRoom(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(), networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
isFeatureEnabled: Boolean = true, isFeatureEnabled: Boolean = true,
analyticsService: AnalyticsService = FakeAnalyticsService(),
): PinnedMessagesListPresenter { ): PinnedMessagesListPresenter {
val timelineProvider = PinnedEventsTimelineProvider( val timelineProvider = PinnedEventsTimelineProvider(
room = room, room = room,
@ -302,6 +311,7 @@ class PinnedMessagesListPresenterTest {
timelineProvider = timelineProvider, timelineProvider = timelineProvider,
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory, actionListPresenterFactory = FakeActionListPresenter.Factory,
analyticsService = analyticsService,
appCoroutineScope = this, appCoroutineScope = this,
) )
} }

View file

@ -45,10 +45,7 @@ class MigrationPresenter @Inject constructor(
LaunchedEffect(migrationStoreVersion) { LaunchedEffect(migrationStoreVersion) {
val migrationValue = migrationStoreVersion ?: return@LaunchedEffect val migrationValue = migrationStoreVersion ?: return@LaunchedEffect
if (migrationValue == -1) { if (migrationValue == -1) {
// Fresh install, no migration needed Timber.d("Fresh install, or previous installed application did not have the migration mechanism.")
Timber.d("Fresh install, no migration needed.")
migrationStore.setApplicationMigrationVersion(lastMigration)
return@LaunchedEffect
} }
if (migrationValue == lastMigration) { if (migrationValue == lastMigration) {
Timber.d("Current app migration version: $migrationValue. No migration needed.") Timber.d("Current app migration version: $migrationValue. No migration needed.")

View file

@ -27,12 +27,9 @@ class MigrationPresenterTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - no migration should occurs on fresh installation, and last version should be stored`() = runTest { fun `present - run all migrations on fresh installation, and last version should be stored`() = runTest {
val migrations = (1..10).map { order -> val migrations = (1..10).map { order ->
FakeAppMigration( FakeAppMigration(order = order)
order = order,
migrateLambda = LambdaNoParamRecorder(ensureNeverCalled = true) { },
)
} }
val store = InMemoryMigrationStore(initialApplicationMigrationVersion = -1) val store = InMemoryMigrationStore(initialApplicationMigrationVersion = -1)
val presenter = createPresenter( val presenter = createPresenter(
@ -44,12 +41,15 @@ class MigrationPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
skipItems(1) skipItems(migrations.size)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit)) assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
} }
assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order }) assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order })
} }
for (migration in migrations) {
migration.migrateLambda.assertions().isCalledOnce()
}
} }
@Test @Test

View file

@ -114,7 +114,7 @@ private fun ElementCallCategory(
validation = callUrlState.validator, validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !isUsingDefaultUrl(value) }, displayValue = { value -> !isUsingDefaultUrl(value) },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrect = false, keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) } onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) }
) )
} }

View file

@ -21,7 +21,7 @@ open class ConfigureTracingStateProvider : PreviewParameterProvider<ConfigureTra
fun aConfigureTracingState() = ConfigureTracingState( fun aConfigureTracingState() = ConfigureTracingState(
targetsToLogLevel = persistentMapOf( targetsToLogLevel = persistentMapOf(
Target.COMMON to LogLevel.INFO, Target.ELEMENT to LogLevel.INFO,
Target.MATRIX_SDK_FFI to LogLevel.WARN, Target.MATRIX_SDK_FFI to LogLevel.WARN,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.ERROR, Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.ERROR,
), ),

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string> <string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string>
<string name="full_screen_intent_banner_title">"Täiusta oma telefonikõnede kogemust"</string> <string name="full_screen_intent_banner_title">"Sinu tõhusad telefonikõned"</string>
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Vali kuidas sa soovid saada teavitusi"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Vali kuidas sa soovid saada teavitusi"</string>
<string name="screen_advanced_settings_developer_mode">"Arendaja valikud"</string> <string name="screen_advanced_settings_developer_mode">"Arendaja valikud"</string>
<string name="screen_advanced_settings_developer_mode_description">"Selle eelistuse sisselülitamisel lisanduvad rakendusse arendaja tööks vajalikud valikud."</string> <string name="screen_advanced_settings_developer_mode_description">"Selle eelistuse sisselülitamisel lisanduvad rakendusse arendaja tööks vajalikud valikud."</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s jooksis kokku viimatu kui seda kasutasid. Kas tahaksid selle kohta meile aruande saata?"</string> <string name="crash_detection_dialog_content">"%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?"</string>
<string name="rageshake_detection_dialog_content">"Tundub, et sa raputad oma nutiseadet ägedalt. Kas sa soovid saata meile veateadet?"</string> <string name="rageshake_detection_dialog_content">"Tundub, et sa raputad oma nutiseadet ägedalt. Kas sa soovid saata meile veateadet?"</string>
<string name="settings_rageshake">"Seadme äge raputamine"</string> <string name="settings_rageshake">"Seadme äge raputamine"</string>
<string name="settings_rageshake_detection_threshold">"Tuvastamise lävi"</string> <string name="settings_rageshake_detection_threshold">"Tuvastamise lävi"</string>

View file

@ -12,6 +12,6 @@
<string name="screen_bug_report_include_logs">"Luba logide saatmine"</string> <string name="screen_bug_report_include_logs">"Luba logide saatmine"</string>
<string name="screen_bug_report_include_screenshot">"Saada ekraanitõmmis"</string> <string name="screen_bug_report_include_screenshot">"Saada ekraanitõmmis"</string>
<string name="screen_bug_report_logs_description">"Tõhusama veaotsingu nimel lisame sinu veateatele logid. Kui sa seda ei soovi, siis lülita antud valik välja."</string> <string name="screen_bug_report_logs_description">"Tõhusama veaotsingu nimel lisame sinu veateatele logid. Kui sa seda ei soovi, siis lülita antud valik välja."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s jooksis kokku viimatu kui seda kasutasid. Kas tahaksid selle kohta meile aruande saata?"</string> <string name="screen_bug_report_rash_logs_alert_title">"%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?"</string>
<string name="screen_bug_report_view_logs">"Vaata logisid"</string> <string name="screen_bug_report_view_logs">"Vaata logisid"</string>
</resources> </resources>

View file

@ -50,7 +50,7 @@ dependencies {
implementation(projects.features.createroom.api) implementation(projects.features.createroom.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)
implementation(projects.features.userprofile.shared) implementation(projects.features.userprofile.shared)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)

View file

@ -33,9 +33,10 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -125,7 +126,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
override fun openAvatarPreview(name: String, url: String) { override fun openAvatarPreview(name: String, url: String) {
backstack.push(NavTarget.AvatarPreview(name, url)) overlay.show(NavTarget.AvatarPreview(name, url))
} }
override fun openPollHistory() { override fun openPollHistory() {
@ -186,7 +187,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
val callback = object : UserProfileNodeHelper.Callback { val callback = object : UserProfileNodeHelper.Callback {
override fun openAvatarPreview(username: String, avatarUrl: String) { override fun openAvatarPreview(username: String, avatarUrl: String) {
backstack.push(NavTarget.AvatarPreview(username, avatarUrl)) overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
} }
override fun onStartDM(roomId: RoomId) { override fun onStartDM(roomId: RoomId) {
@ -252,6 +253,6 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView() BackstackWithOverlayBox(modifier)
} }
} }

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.leaveroom.api.LeaveRoomView
@ -80,6 +81,8 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -111,9 +114,9 @@ fun RoomDetailsView(
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
LeaveRoomView(state = state.leaveRoomState) LeaveRoomView(state = state.leaveRoomState)
@ -270,8 +273,8 @@ private fun MainActionsSection(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
val roomNotificationSettings = state.roomNotificationSettings val roomNotificationSettings = state.roomNotificationSettings
@ -330,8 +333,8 @@ private fun RoomHeaderSection(
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CompositeAvatar( CompositeAvatar(
@ -340,8 +343,8 @@ private fun RoomHeaderSection(
user.getAvatarData(size = AvatarSize.RoomHeader) user.getAvatarData(size = AvatarSize.RoomHeader)
}.toPersistentList(), }.toPersistentList(),
modifier = Modifier modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar) .testTag(TestTags.roomDetailAvatar)
) )
TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value) TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
} }
@ -357,8 +360,8 @@ private fun DmHeaderSection(
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
DmAvatars( DmAvatars(
@ -509,6 +512,7 @@ private fun PinnedMessagesItem(
pinnedMessagesCount: Int?, pinnedMessagesCount: Int?,
onPinnedMessagesClick: () -> Unit, onPinnedMessagesClick: () -> Unit,
) { ) {
val analyticsService = LocalAnalyticsService.current
ListItem( ListItem(
headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) }, headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
@ -520,7 +524,10 @@ private fun PinnedMessagesItem(
} else { } else {
ListItemContent.Text(pinnedMessagesCount.toString()) ListItemContent.Text(pinnedMessagesCount.toString())
}, },
onClick = onPinnedMessagesClick, onClick = {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
onPinnedMessagesClick()
}
) )
} }

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,6 +23,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
@ -102,8 +102,8 @@ private fun RoomSpecificNotificationSettingsView(
underline = false, underline = false,
bold = true, bold = true,
) )
ClickableText( ClickableLinkText(
text = text, annotatedString = text,
onClick = { onClick = {
onShowGlobalNotifications() onShowGlobalNotifications()
}, },

View file

@ -11,8 +11,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -22,8 +24,8 @@ fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
displayName: String = A_ROOM_NAME, displayName: String = A_ROOM_NAME,
rawName: String? = displayName, rawName: String? = displayName,
topic: String? = "A topic", topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = "https://matrix.org/avatar.jpg", avatarUrl: String? = AN_AVATAR_URL,
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,

View file

@ -33,7 +33,10 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -129,7 +132,12 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state is updated with roomInfo if it exists`() = runTest { fun `present - initial state is updated with roomInfo if it exists`() = runTest {
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg", pinnedEventIds = listOf(AN_EVENT_ID)) val roomInfo = aRoomInfo(
name = A_ROOM_NAME,
topic = A_ROOM_TOPIC,
avatarUrl = AN_AVATAR_URL,
pinnedEventIds = listOf(AN_EVENT_ID),
)
val room = aMatrixRoom( val room = aMatrixRoom(
canInviteResult = { Result.success(true) }, canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },

View file

@ -39,6 +39,7 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -218,7 +219,10 @@ class RoomListPresenter @Inject constructor(
} }
} }
val needsSlidingSyncMigration by produceState(false) { val needsSlidingSyncMigration by produceState(false) {
value = client.isNativeSlidingSyncSupported() && !client.isUsingNativeSlidingSync() value = runCatching {
// Note: this can fail when the session is destroyed from another client.
client.isNativeSlidingSyncSupported() && !client.isUsingNativeSlidingSync()
}.getOrNull().orFalse()
} }
return when { return when {
showEmpty -> RoomListContentState.Empty showEmpty -> RoomListContentState.Empty

View file

@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -137,7 +137,7 @@ private fun RoomSummaryScaffoldRow(
val clickModifier = Modifier.combinedClickable( val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) }, onClick = { onClick(room) },
onLongClick = { onLongClick(room) }, onLongClick = { onLongClick(room) },
indication = rememberRipple(), indication = ripple(),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )

View file

@ -116,7 +116,7 @@ fun RoomListFiltersView(
val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat() val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat()
RoomListFilterView( RoomListFilterView(
modifier = Modifier modifier = Modifier
.animateItemPlacement() .animateItem()
.zIndex(zIndex), .zIndex(zIndex),
roomListFilter = filterWithSelection.filter, roomListFilter = filterWithSelection.filter,
selected = filterWithSelection.isSelected, selected = filterWithSelection.isSelected,

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйсці і абнавіць"</string>
<string name="banner_set_up_recovery_title">"Наладзіць аднаўленне"</string>
<string name="confirm_recovery_key_banner_message">"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."</string> <string name="confirm_recovery_key_banner_message">"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."</string>
<string name="confirm_recovery_key_banner_title">"Увядзіце ключ аднаўлення"</string> <string name="confirm_recovery_key_banner_title">"Увядзіце ключ аднаўлення"</string>
<string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string> <string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string>

View file

@ -9,7 +9,7 @@
<string name="confirm_recovery_key_banner_message">"Sinu vestluste varukoopia pole hetkel sünkroonis. Säilitamaks ligipääsu vestluse varukoopiale palun sisesta oma taastevõti."</string> <string name="confirm_recovery_key_banner_message">"Sinu vestluste varukoopia pole hetkel sünkroonis. Säilitamaks ligipääsu vestluse varukoopiale palun sisesta oma taastevõti."</string>
<string name="confirm_recovery_key_banner_title">"Sisesta oma taastevõti"</string> <string name="confirm_recovery_key_banner_title">"Sisesta oma taastevõti"</string>
<string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string> <string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string>
<string name="full_screen_intent_banner_title">"Täiusta oma telefonikõnede kogemust"</string> <string name="full_screen_intent_banner_title">"Sinu tõhusad telefonikõned"</string>
<string name="screen_invites_decline_chat_message">"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"</string> <string name="screen_invites_decline_chat_message">"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Lükka kutse tagasi"</string> <string name="screen_invites_decline_chat_title">"Lükka kutse tagasi"</string>
<string name="screen_invites_decline_direct_chat_message">"Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?"</string>

View file

@ -16,6 +16,7 @@
<string name="screen_create_new_recovery_key_list_item_4">"Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"</string> <string name="screen_create_new_recovery_key_list_item_4">"Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"</string> <string name="screen_create_new_recovery_key_list_item_5">"Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"</string>
<string name="screen_create_new_recovery_key_title">"Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"</string> <string name="screen_create_new_recovery_key_title">"Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"</string>
<string name="screen_encryption_reset_action_continue_reset">"Працягнуць скід"</string>
<string name="screen_encryption_reset_bullet_1">"Дадзеныя вашага ўліковага запісу, кантакты, налады і спіс чатаў будуць захаваны"</string> <string name="screen_encryption_reset_bullet_1">"Дадзеныя вашага ўліковага запісу, кантакты, налады і спіс чатаў будуць захаваны"</string>
<string name="screen_encryption_reset_bullet_2">"Вы страціце існуючую гісторыю паведамленняў"</string> <string name="screen_encryption_reset_bullet_2">"Вы страціце існуючую гісторыю паведамленняў"</string>
<string name="screen_encryption_reset_bullet_3">"Вам трэба будзе зноў запэўніць ўсе вашы існуючыя прылады і кантакты"</string> <string name="screen_encryption_reset_bullet_3">"Вам трэба будзе зноў запэўніць ўсе вашы існуючыя прылады і кантакты"</string>

View file

@ -19,7 +19,7 @@
<string name="screen_session_verification_enter_recovery_key">"Sisesta taastevõti"</string> <string name="screen_session_verification_enter_recovery_key">"Sisesta taastevõti"</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Saamaks ligipääsu krüptitud sõnumite ajaloole tõesta et tegemist on sinuga."</string> <string name="screen_session_verification_open_existing_session_subtitle">"Saamaks ligipääsu krüptitud sõnumite ajaloole tõesta et tegemist on sinuga."</string>
<string name="screen_session_verification_open_existing_session_title">"Ava olemasolev sessioon"</string> <string name="screen_session_verification_open_existing_session_title">"Ava olemasolev sessioon"</string>
<string name="screen_session_verification_positive_button_canceled">"Proovi uuesti verifitseerimist"</string> <string name="screen_session_verification_positive_button_canceled">"Proovi verifitseerimist uuesti"</string>
<string name="screen_session_verification_positive_button_initial">"Ma olen valmis alustama"</string> <string name="screen_session_verification_positive_button_initial">"Ma olen valmis alustama"</string>
<string name="screen_session_verification_positive_button_verifying_ongoing">"Ootame kinnitust sobivusele"</string> <string name="screen_session_verification_positive_button_verifying_ongoing">"Ootame kinnitust sobivusele"</string>
<string name="screen_session_verification_ready_subtitle">"Võrdle unikaalset emojide kombinatsiooni"</string> <string name="screen_session_verification_ready_subtitle">"Võrdle unikaalset emojide kombinatsiooni"</string>

View file

@ -24,7 +24,7 @@ media3 = "1.4.1"
camera = "1.3.4" camera = "1.3.4"
# Compose # Compose
compose_bom = "2024.08.00" compose_bom = "2024.09.02"
composecompiler = "1.5.15" composecompiler = "1.5.15"
# Coroutines # Coroutines
@ -39,7 +39,7 @@ test_core = "1.6.1"
#other #other
coil = "2.7.0" coil = "2.7.0"
datetime = "0.6.0" datetime = "0.6.0"
dependencyAnalysis = "2.0.1" dependencyAnalysis = "2.1.0"
serialization_json = "1.6.3" serialization_json = "1.6.3"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.4.0" appyx = "1.4.0"
@ -67,7 +67,7 @@ kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.2" gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:33.2.0" google_firebase_bom = "com.google.firebase:firebase-bom:33.3.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
@ -96,7 +96,7 @@ androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = "androidx.startup:startup-runtime:1.1.1" androidx_startup = "androidx.startup:startup-runtime:1.2.0"
androidx_preference = "androidx.preference:preference:1.2.1" androidx_preference = "androidx.preference:preference:1.2.1"
androidx_webkit = "androidx.webkit:webkit:1.11.0" androidx_webkit = "androidx.webkit:webkit:1.11.0"
@ -152,7 +152,7 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" } coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "0.0.7" } compound = { module = "io.element.android:compound-android", version = "0.1.0" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7" kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
@ -162,7 +162,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.47" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.48"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -171,13 +171,13 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.0" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.1"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.2.0" maplibre = "org.maplibre.gl:android-sdk:11.4.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.1"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0"
mapbox_android_gestures = "com.mapbox.mapboxsdk:mapbox-android-gestures:0.7.0" mapbox_android_gestures = "com.mapbox.mapboxsdk:mapbox-android-gestures:0.7.0"
opusencoder = "io.element.android:opusencoder:1.1.0" opusencoder = "io.element.android:opusencoder:1.1.0"
@ -185,10 +185,10 @@ kotlinpoet = "com.squareup:kotlinpoet:1.18.1"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.6.1" posthog = "com.posthog:posthog-android:3.7.3"
sentry = "io.sentry:sentry-android:7.14.0" sentry = "io.sentry:sentry-android:7.14.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.25.0"
# Emojibase # Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3" matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"

View file

@ -71,7 +71,10 @@ fun Context.copyToClipboard(
* Shows notification settings for the current app. * Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings * In android O will directly opens the notification settings, in lower version it will show the App settings
*/ */
fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher<Intent>? = null) { fun Context.startNotificationSettingsIntent(
activityResultLauncher: ActivityResultLauncher<Intent>? = null,
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent() val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
@ -85,10 +88,14 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu
intent.data = Uri.fromParts("package", packageName, null) intent.data = Uri.fromParts("package", packageName, null)
} }
if (activityResultLauncher != null) { try {
activityResultLauncher.launch(intent) if (activityResultLauncher != null) {
} else { activityResultLauncher.launch(intent)
startActivity(intent) } else {
startActivity(intent)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
toast(noActivityFoundMessage)
} }
} }

View file

@ -86,13 +86,13 @@ fun ElementLogoAtom(
.size(size.logoSize) .size(size.logoSize)
// Do the same double shadow than on Figma... // Do the same double shadow than on Figma...
.shadow( .shadow(
elevation = 25.dp, elevation = 35.dp,
clip = false, clip = false,
shape = CircleShape, shape = CircleShape,
ambientColor = logoShadowColor, ambientColor = logoShadowColor,
) )
.shadow( .shadow(
elevation = 25.dp, elevation = 35.dp,
clip = false, clip = false,
shape = CircleShape, shape = CircleShape,
ambientColor = Color(0x80000000), ambientColor = Color(0x80000000),

View file

@ -7,41 +7,19 @@
package io.element.android.libraries.designsystem.colors package io.element.android.libraries.designsystem.colors
import androidx.collection.LruCache import androidx.compose.runtime.Composable
import io.element.android.compound.theme.AvatarColors import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.avatarColorsDark import io.element.android.compound.theme.avatarColors
import io.element.android.compound.theme.avatarColorsLight
object AvatarColorsProvider { object AvatarColorsProvider {
private val cache = LruCache<String, AvatarColors>(200) @Composable
private var currentThemeIsLight = true fun provide(id: String): AvatarColors {
return avatarColors().let { colors ->
fun provide(id: String, isLightTheme: Boolean): AvatarColors { colors[id.toHash(colors.size)]
if (currentThemeIsLight != isLightTheme) {
currentThemeIsLight = isLightTheme
cache.evictAll()
} }
val valueFromCache = cache.get(id)
return if (valueFromCache != null) {
valueFromCache
} else {
val colors = avatarColors(id, isLightTheme)
cache.put(id, colors)
colors
}
}
private fun avatarColors(id: String, isLightTheme: Boolean): AvatarColors {
val hash = id.toHash()
val colors = if (isLightTheme) {
avatarColorsLight[hash]
} else {
avatarColorsDark[hash]
}
return colors
} }
} }
internal fun String.toHash(): Int { internal fun String.toHash(maxSize: Int): Int {
return toList().sumOf { it.code } % avatarColorsLight.size return toList().sumOf { it.code } % maxSize
} }

View file

@ -364,7 +364,7 @@ fun Modifier.avatarBloom(
) )
} else { } else {
// There is no URL so we'll generate an avatar with the initials and use that as the bloom source // There is no URL so we'll generate an avatar with the initials and use that as the bloom source
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide(avatarData.id)
val initialsBitmap = initialsBitmap( val initialsBitmap = initialsBitmap(
width = BloomDefaults.ENCODE_SIZE_PX.toDp(), width = BloomDefaults.ENCODE_SIZE_PX.toDp(),
height = BloomDefaults.ENCODE_SIZE_PX.toDp(), height = BloomDefaults.ENCODE_SIZE_PX.toDp(),
@ -538,7 +538,7 @@ class InitialsColorIntProvider : PreviewParameterProvider<Int> {
@ShowkaseComposable(group = PreviewGroup.Bloom) @ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorIntProvider::class) color: Int) { internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorIntProvider::class) color: Int) {
ElementPreview { ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color", ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide("$color")
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground) val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode( val hash = BlurHash.encode(
bitmap = bitmap.asAndroidBitmap(), bitmap = bitmap.asAndroidBitmap(),

View file

@ -23,7 +23,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import timber.log.Timber
const val LINK_TAG = "URL" const val LINK_TAG = "URL"
@ -65,7 +66,6 @@ fun ClickableLinkText(
) )
} }
@OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun ClickableLinkText( fun ClickableLinkText(
annotatedString: AnnotatedString, annotatedString: AnnotatedString,
@ -106,14 +106,18 @@ fun ClickableLinkText(
) { offset -> ) { offset ->
layoutResult.value?.let { layoutResult -> layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset) val position = layoutResult.getOffsetForPosition(offset)
val linkUrlAnnotations = annotatedString.getUrlAnnotations(position, position) val linkUrlAnnotations = annotatedString.getLinkAnnotations(position, position)
.map { AnnotatedString.Range(it.item.url, it.start, it.end, linkAnnotationTag) } .map { AnnotatedString.Range(it.item, it.start, it.end, linkAnnotationTag) }
val linkStringAnnotations = linkUrlAnnotations + val linkStringAnnotations = linkUrlAnnotations +
annotatedString.getStringAnnotations(linkAnnotationTag, position, position) annotatedString.getStringAnnotations(linkAnnotationTag, position, position)
if (linkStringAnnotations.isEmpty()) { if (linkStringAnnotations.isEmpty()) {
onClick() onClick()
} else { } else {
uriHandler.openUri(linkStringAnnotations.first().item) when (val annotation = linkStringAnnotations.first().item) {
is LinkAnnotation.Url -> uriHandler.openUri(annotation.url)
is String -> uriHandler.openUri(annotation)
else -> Timber.e("Unknown link annotation: $annotation")
}
} }
} }
} }
@ -129,7 +133,6 @@ fun ClickableLinkText(
) )
} }
@OptIn(ExperimentalTextApi::class)
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString { fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this val original = this
val spannable = SpannableString(this.text) val spannable = SpannableString(this.text)
@ -141,7 +144,7 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
for (span in spans) { for (span in spans) {
val start = spannable.getSpanStart(span) val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span) val end = spannable.getSpanEnd(span)
if (original.getUrlAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) { if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com) // Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
if (start > 0 && !spannable[start - 1].isWhitespace()) continue if (start > 0 && !spannable[start - 1].isWhitespace()) continue
addStyle( addStyle(

View file

@ -115,7 +115,7 @@ private fun InitialsAvatar(
forcedAvatarSize: Dp?, forcedAvatarSize: Dp?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide(avatarData.id)
Box( Box(
modifier.background(color = avatarColors.background) modifier.background(color = avatarColors.background)
) { ) {

View file

@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.avatarColorsLight import io.element.android.compound.theme.avatarColors
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -27,7 +27,7 @@ internal fun UserAvatarColorsPreview() = ElementPreview {
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
repeat(avatarColorsLight.size) { repeat(avatarColors().size) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -14,9 +14,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -92,7 +92,7 @@ fun GradientFloatingActionButton(
enabled = true, enabled = true,
onClick = onClick, onClick = onClick,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = Color.White) indication = ripple(color = Color.White)
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {

View file

@ -16,9 +16,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -45,7 +45,7 @@ fun MainActionButton(
enabled: Boolean = true, enabled: Boolean = true,
contentDescription: String = title, contentDescription: String = title,
) { ) {
val ripple = rememberRipple(bounded = false) val ripple = ripple(bounded = false)
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Column( Column(
modifier modifier

View file

@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -111,7 +111,7 @@ fun SuperButton(
enabled = enabled, enabled = enabled,
onClick = onClick, onClick = onClick,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple() indication = ripple()
) )
.padding(contentPadding), .padding(contentPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center

View file

@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
/** /**
* Inspired from https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose * Inspired from https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose

View file

@ -7,9 +7,9 @@
package io.element.android.libraries.designsystem.components.tooltip package io.element.android.libraries.designsystem.components.tooltip
import androidx.compose.material3.CaretScope
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TooltipScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -19,7 +19,7 @@ import androidx.compose.material3.PlainTooltip as M3PlainTooltip
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CaretScope.PlainTooltip( fun TooltipScope.PlainTooltip(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentColor: Color = ElementTheme.colors.textOnSolidPrimary, contentColor: Color = ElementTheme.colors.textOnSolidPrimary,
containerColor: Color = ElementTheme.colors.bgActionPrimaryRest, containerColor: Color = ElementTheme.colors.bgActionPrimaryRest,

View file

@ -7,8 +7,8 @@
package io.element.android.libraries.designsystem.components.tooltip package io.element.android.libraries.designsystem.components.tooltip
import androidx.compose.material3.CaretScope
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipScope
import androidx.compose.material3.TooltipState import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -19,7 +19,7 @@ import androidx.compose.material3.TooltipBox as M3TooltipBox
@Composable @Composable
fun TooltipBox( fun TooltipBox(
positionProvider: PopupPositionProvider, positionProvider: PopupPositionProvider,
tooltip: @Composable CaretScope.() -> Unit, tooltip: @Composable TooltipScope.() -> Unit,
state: TooltipState, state: TooltipState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusable: Boolean = true, focusable: Boolean = true,

View file

@ -8,8 +8,6 @@
package io.element.android.libraries.designsystem.modifiers package io.element.android.libraries.designsystem.modifiers
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
/** /**
* Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise.
@ -18,15 +16,8 @@ fun Modifier.applyIf(
condition: Boolean, condition: Boolean,
ifTrue: Modifier.() -> Modifier, ifTrue: Modifier.() -> Modifier,
ifFalse: (Modifier.() -> Modifier)? = null ifFalse: (Modifier.() -> Modifier)? = null
): Modifier = this then inspectable( ): Modifier = this then when {
inspectorInfo = debugInspectorInfo { condition -> ifTrue(Modifier)
name = "applyIf" ifFalse != null -> ifFalse(Modifier)
value = condition else -> Modifier
}
) {
this then when {
condition -> ifTrue(Modifier)
ifFalse != null -> ifFalse(Modifier)
else -> Modifier
}
} }

View file

@ -48,8 +48,7 @@ fun Modifier.blurredShapeShadow(
offsetX: Dp = 0.dp, offsetX: Dp = 0.dp,
offsetY: Dp = 0.dp, offsetY: Dp = 0.dp,
blurRadius: Dp = 0.dp, blurRadius: Dp = 0.dp,
) = then( ) = drawBehind {
drawBehind {
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val path = Path().apply { val path = Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
@ -78,8 +77,7 @@ fun Modifier.blurredShapeShadow(
) )
} }
} }
} }
)
fun Modifier.blurCompat( fun Modifier.blurCompat(
radius: Dp, radius: Dp,

View file

@ -12,10 +12,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = then( fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = pointerInput(Unit) {
pointerInput(Unit) { detectTapGestures(onTap = {
detectTapGestures(onTap = { focusManager.clearFocus()
focusManager.clearFocus() })
}) }
}
)

View file

@ -45,8 +45,8 @@ fun BottomSheetDragHandle(
.fillMaxWidth() .fillMaxWidth()
.requiredHeight(72.dp) .requiredHeight(72.dp)
.offset(y = 18.dp) .offset(y = 18.dp)
.clip(MaterialTheme.shapes.extraLarge) .clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.surface)
.border(0.5.dp, ElementTheme.colors.borderDisabled, MaterialTheme.shapes.extraLarge) .border(0.5.dp, ElementTheme.colors.borderDisabled, MaterialTheme.shapes.extraLarge)
) )

View file

@ -32,7 +32,7 @@ fun BottomSheetScaffold(
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetShape: Shape = BottomSheetDefaults.ExpandedShape, sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
sheetContainerColor: Color = BottomSheetDefaults.ContainerColor, sheetContainerColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetContainerColor), sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = BottomSheetDefaults.Elevation, sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,

View file

@ -9,8 +9,10 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@ -25,12 +27,14 @@ fun CircularProgressIndicator(
progress: () -> Float, progress: () -> Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = ProgressIndicatorDefaults.circularColor, color: Color = ProgressIndicatorDefaults.circularColor,
trackColor: Color = ProgressIndicatorDefaults.circularDeterminateTrackColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth
) { ) {
androidx.compose.material3.CircularProgressIndicator( androidx.compose.material3.CircularProgressIndicator(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
color = color, color = color,
trackColor = trackColor,
strokeWidth = strokeWidth, strokeWidth = strokeWidth,
) )
} }
@ -39,6 +43,7 @@ fun CircularProgressIndicator(
fun CircularProgressIndicator( fun CircularProgressIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = ProgressIndicatorDefaults.circularColor, color: Color = ProgressIndicatorDefaults.circularColor,
trackColor: Color = ProgressIndicatorDefaults.circularIndeterminateTrackColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
) { ) {
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@ -47,12 +52,14 @@ fun CircularProgressIndicator(
modifier = modifier, modifier = modifier,
progress = { 0.75F }, progress = { 0.75F },
color = color, color = color,
trackColor = trackColor,
strokeWidth = strokeWidth, strokeWidth = strokeWidth,
) )
} else { } else {
androidx.compose.material3.CircularProgressIndicator( androidx.compose.material3.CircularProgressIndicator(
modifier = modifier, modifier = modifier,
color = color, color = color,
trackColor = trackColor,
strokeWidth = strokeWidth, strokeWidth = strokeWidth,
) )
} }
@ -61,12 +68,18 @@ fun CircularProgressIndicator(
@Preview(group = PreviewGroup.Progress) @Preview(group = PreviewGroup.Progress)
@Composable @Composable
internal fun CircularProgressIndicatorPreview() = ElementThemedPreview(vertical = false) { internal fun CircularProgressIndicatorPreview() = ElementThemedPreview(vertical = false) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(
modifier = Modifier.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Indeterminate progress // Indeterminate progress
Text("Indeterminate")
CircularProgressIndicator() CircularProgressIndicator()
// Fixed progress // Fixed progress
Text("Fixed progress")
CircularProgressIndicator( CircularProgressIndicator(
progress = { 0.90F } progress = { 0.50F }
) )
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@ -42,12 +43,12 @@ fun ModalBottomSheet(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
shape: Shape = BottomSheetDefaults.ExpandedShape, shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor, containerColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(containerColor), contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = if (ElementTheme.isLightTheme) 0.dp else BottomSheetDefaults.Elevation, tonalElevation: Dp = if (ElementTheme.isLightTheme) 0.dp else BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor, scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
@ -61,7 +62,7 @@ fun ModalBottomSheet(
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
scrimColor = scrimColor, scrimColor = scrimColor,
dragHandle = dragHandle, dragHandle = dragHandle,
windowInsets = windowInsets, contentWindowInsets = contentWindowInsets,
content = content, content = content,
) )
} }

View file

@ -16,9 +16,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@ -56,8 +57,10 @@ fun <T> SearchBar(
tonalElevation: Dp = SearchBarDefaults.TonalElevation, tonalElevation: Dp = SearchBarDefaults.TonalElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets, windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
inactiveColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(), inactiveBarColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(),
activeColors: SearchBarColors = ElementSearchBarDefaults.activeColors(), activeBarColors: SearchBarColors = ElementSearchBarDefaults.activeColors(),
inactiveTextInputColors: TextFieldColors = ElementSearchBarDefaults.inactiveInputFieldColors(),
activeTextInputColors: TextFieldColors = ElementSearchBarDefaults.activeInputFieldColors(),
contentPrefix: @Composable ColumnScope.() -> Unit = {}, contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {}, contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(T) -> Unit = {}, resultHandler: @Composable ColumnScope.(T) -> Unit = {},
@ -69,51 +72,58 @@ fun <T> SearchBar(
focusManager.clearFocus() focusManager.clearFocus()
} }
androidx.compose.material3.SearchBar( SearchBar(
query = query, inputField = {
onQueryChange = onQueryChange, SearchBarDefaults.InputField(
onSearch = { focusManager.clearFocus() }, query = query,
active = active, onQueryChange = onQueryChange,
onActiveChange = onActiveChange, onSearch = { focusManager.clearFocus() },
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp), expanded = active,
enabled = enabled, onExpandedChange = onActiveChange,
placeholder = { enabled = enabled,
Text(text = placeHolderTitle) placeholder = {
}, Text(text = placeHolderTitle)
leadingIcon = if (showBackButton && active) { },
{ BackButton(onClick = { onActiveChange(false) }) } leadingIcon = if (showBackButton && active) {
} else { { BackButton(onClick = { onActiveChange(false) }) }
null } else {
}, null
trailingIcon = when { },
active && query.isNotEmpty() -> { trailingIcon = when {
{ active && query.isNotEmpty() -> {
IconButton(onClick = { onQueryChange("") }) { {
Icon( IconButton(onClick = { onQueryChange("") }) {
imageVector = CompoundIcons.Close(), Icon(
contentDescription = stringResource(CommonStrings.action_clear), imageVector = CompoundIcons.Close(),
) contentDescription = stringResource(CommonStrings.action_clear),
)
}
}
} }
}
}
!active -> { !active -> {
{ {
Icon( Icon(
imageVector = CompoundIcons.Search(), imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search), contentDescription = stringResource(CommonStrings.action_search),
tint = MaterialTheme.colorScheme.tertiary, tint = ElementTheme.materialColors.tertiary,
) )
} }
} }
else -> null else -> null
},
interactionSource = interactionSource,
colors = if (active) activeTextInputColors else inactiveTextInputColors,
)
}, },
expanded = active,
onExpandedChange = onActiveChange,
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
shape = shape, shape = shape,
colors = if (active) activeColors else inactiveColors, colors = if (active) activeBarColors else inactiveBarColors,
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
windowInsets = windowInsets, windowInsets = windowInsets,
interactionSource = interactionSource,
content = { content = {
contentPrefix() contentPrefix()
when (resultState) { when (resultState) {
@ -128,7 +138,7 @@ fun <T> SearchBar(
Text( Text(
text = stringResource(CommonStrings.common_no_results), text = stringResource(CommonStrings.common_no_results),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary, color = ElementTheme.materialColors.tertiary,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -147,28 +157,34 @@ object ElementSearchBarDefaults {
@Composable @Composable
fun inactiveColors() = SearchBarDefaults.colors( fun inactiveColors() = SearchBarDefaults.colors(
containerColor = ElementTheme.materialColors.surfaceVariant, containerColor = ElementTheme.materialColors.surfaceVariant,
inputFieldColors = TextFieldDefaults.colors( dividerColor = ElementTheme.materialColors.outline,
unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, )
focusedPlaceholderColor = ElementTheme.colors.textDisabled,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary, @Composable
focusedLeadingIconColor = MaterialTheme.colorScheme.primary, fun inactiveInputFieldColors() = TextFieldDefaults.colors(
unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary, unfocusedPlaceholderColor = ElementTheme.colors.textDisabled,
focusedTrailingIconColor = MaterialTheme.colorScheme.primary, focusedPlaceholderColor = ElementTheme.colors.textDisabled,
) unfocusedLeadingIconColor = ElementTheme.materialColors.primary,
focusedLeadingIconColor = ElementTheme.materialColors.primary,
unfocusedTrailingIconColor = ElementTheme.materialColors.primary,
focusedTrailingIconColor = ElementTheme.materialColors.primary,
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun activeColors() = SearchBarDefaults.colors( fun activeColors() = SearchBarDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
inputFieldColors = TextFieldDefaults.colors( dividerColor = ElementTheme.materialColors.outline,
unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, )
focusedPlaceholderColor = ElementTheme.colors.textDisabled,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary, @Composable
focusedLeadingIconColor = MaterialTheme.colorScheme.primary, fun activeInputFieldColors() = TextFieldDefaults.colors(
unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary, unfocusedPlaceholderColor = ElementTheme.colors.textDisabled,
focusedTrailingIconColor = MaterialTheme.colorScheme.primary, focusedPlaceholderColor = ElementTheme.colors.textDisabled,
) unfocusedLeadingIconColor = ElementTheme.materialColors.primary,
focusedLeadingIconColor = ElementTheme.materialColors.primary,
unfocusedTrailingIconColor = ElementTheme.materialColors.primary,
focusedTrailingIconColor = ElementTheme.materialColors.primary,
) )
} }

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -64,7 +65,12 @@ fun TextField(
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape, shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors() colors: TextFieldColors = TextFieldDefaults.colors(
unfocusedContainerColor = ElementTheme.colors.bgSubtleSecondary,
focusedContainerColor = ElementTheme.colors.bgSubtleSecondary,
disabledContainerColor = ElementTheme.colors.bgSubtleSecondary,
errorContainerColor = ElementTheme.colors.bgSubtleSecondary,
)
) { ) {
androidx.compose.material3.TextField( androidx.compose.material3.TextField(
value = value, value = value,

View file

@ -70,7 +70,7 @@ fun CustomBottomSheetScaffold(
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
sheetShape: Shape = BottomSheetDefaults.ExpandedShape, sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
sheetContainerColor: Color = BottomSheetDefaults.ContainerColor, sheetContainerColor: Color = Color.White,
sheetContentColor: Color = contentColorFor(sheetContainerColor), sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = BottomSheetDefaults.Elevation, sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
@ -367,6 +367,12 @@ private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : Dragg
return anchors == other.anchors return anchors == other.anchors
} }
override fun forEach(block: (anchor: T, position: Float) -> Unit) {
for (anchor in anchors) {
block(anchor.key, anchor.value)
}
}
override fun hashCode() = 31 * anchors.hashCode() override fun hashCode() = 31 * anchors.hashCode()
override fun toString() = "MapDraggableAnchors($anchors)" override fun toString() = "MapDraggableAnchors($anchors)"
@ -381,7 +387,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
): NestedScrollConnection = object : NestedScrollConnection { ): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat() val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) { return if (delta < 0 && source == NestedScrollSource.UserInput) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset() sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else { } else {
Offset.Zero Offset.Zero
@ -393,7 +399,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
available: Offset, available: Offset,
source: NestedScrollSource source: NestedScrollSource
): Offset { ): Offset {
return if (source == NestedScrollSource.Drag) { return if (source == NestedScrollSource.UserInput) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else { } else {
Offset.Zero Offset.Zero

View file

@ -7,7 +7,9 @@
package io.element.android.libraries.designsystem.theme.components.bottomsheet package io.element.android.libraries.designsystem.theme.components.bottomsheet
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.gestures.snapTo
@ -201,14 +203,12 @@ constructor(
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
* *
* @param targetValue The target value of the animation * @param targetValue The target value of the animation
* @param velocity The velocity of the animation
*/ */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
internal suspend fun animateTo( internal suspend fun animateTo(
targetValue: SheetValue, targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity
) { ) {
anchoredDraggableState.animateTo(targetValue, velocity) anchoredDraggableState.animateTo(targetValue)
} }
/** /**
@ -235,7 +235,8 @@ constructor(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState( internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState(
initialValue = initialValue, initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults.AnimationSpec, snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
decayAnimationSpec = AnchoredDraggableDefaults.DecayAnimationSpec,
confirmValueChange = confirmValueChange, confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } }, positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } } velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
@ -298,5 +299,10 @@ internal object AnchoredDraggableDefaults {
@get:ExperimentalMaterial3Api @get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>() val SnapAnimationSpec = SpringSpec<Float>()
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val DecayAnimationSpec = exponentialDecay<Float>()
} }

View file

@ -10,10 +10,10 @@ package io.element.android.libraries.designsystem.utils
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
@Composable @Composable
fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) {

View file

@ -8,32 +8,26 @@
package io.element.android.libraries.designsystem.colors package io.element.android.libraries.designsystem.colors
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.avatarColorsDark
import io.element.android.compound.theme.avatarColorsLight
import org.junit.Test import org.junit.Test
class AvatarColorsTest { class AvatarColorsTest {
@Test private val maxSize = 6
fun `ensure the size of the avatar color are equal for light and dark theme`() {
assertThat(avatarColorsDark.size).isEqualTo(avatarColorsLight.size)
}
@Test @Test
fun `compute string hash`() { fun `compute string hash`() {
assertThat("@alice:domain.org".toHash()).isEqualTo(6) assertThat("@alice:domain.org".toHash(maxSize)).isEqualTo(0)
assertThat("@bob:domain.org".toHash()).isEqualTo(3) assertThat("@bob:domain.org".toHash(maxSize)).isEqualTo(1)
assertThat("@charlie:domain.org".toHash()).isEqualTo(0) assertThat("@charlie:domain.org".toHash(maxSize)).isEqualTo(2)
} }
@Test @Test
fun `compute string hash reverse`() { fun `compute string hash reverse`() {
assertThat("0".toHash()).isEqualTo(0) assertThat("0".toHash(maxSize)).isEqualTo(0)
assertThat("1".toHash()).isEqualTo(1) assertThat("1".toHash(maxSize)).isEqualTo(1)
assertThat("2".toHash()).isEqualTo(2) assertThat("2".toHash(maxSize)).isEqualTo(2)
assertThat("3".toHash()).isEqualTo(3) assertThat("3".toHash(maxSize)).isEqualTo(3)
assertThat("4".toHash()).isEqualTo(4) assertThat("4".toHash(maxSize)).isEqualTo(4)
assertThat("5".toHash()).isEqualTo(5) assertThat("5".toHash(maxSize)).isEqualTo(5)
assertThat("6".toHash()).isEqualTo(6) assertThat("6".toHash(maxSize)).isEqualTo(0)
assertThat("7".toHash()).isEqualTo(7) assertThat("7".toHash(maxSize)).isEqualTo(1)
} }
} }

View file

@ -123,9 +123,9 @@ enum class FeatureFlags(
defaultValue = { true }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
InvisibleCrypto( OnlySignedDeviceIsolationMode(
key = "feature.invisibleCrypto", key = "feature.onlySignedDeviceIsolationMode",
title = "Invisible Crypto", title = "Exclude not secure devices when sending/receiving messages",
description = "This setting controls how end-to-end encryption (E2E) keys are shared." + description = "This setting controls how end-to-end encryption (E2E) keys are shared." +
" Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners." + " Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners." +
" You'll have to stop and re-open the app manually for that setting to take effect.", " You'll have to stop and re-open the app manually for that setting to take effect.",

View file

@ -32,10 +32,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation

View file

@ -94,7 +94,7 @@ sealed interface NotificationContent {
data object RoomHistoryVisibility : StateEvent data object RoomHistoryVisibility : StateEvent
data object RoomJoinRules : StateEvent data object RoomJoinRules : StateEvent
data class RoomMemberContent( data class RoomMemberContent(
val userId: String, val userId: UserId,
val membershipState: RoomMembershipState val membershipState: RoomMembershipState
) : StateEvent ) : StateEvent
@ -108,6 +108,10 @@ sealed interface NotificationContent {
data object SpaceChild : StateEvent data object SpaceChild : StateEvent
data object SpaceParent : StateEvent data object SpaceParent : StateEvent
} }
data class Invite(
val senderId: UserId,
) : NotificationContent
} }
enum class CallNotifyType { enum class CallNotifyType {

View file

@ -9,8 +9,7 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationService { interface NotificationService {
suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result<NotificationData?> suspend fun getNotification(roomId: RoomId, eventId: EventId): Result<NotificationData?>
} }

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