Merge branch 'release/0.7.1' into main

This commit is contained in:
Benoit Marty 2024-10-25 11:53:32 +02:00
commit 374425cb11
1017 changed files with 5714 additions and 4017 deletions

View file

@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.9.1 - uses: mobile-dev-inc/action-maestro-cloud@v1.9.2
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

View file

@ -1,3 +1,28 @@
Changes in Element X v0.7.0 (2024-10-10)
========================================
## What's Changed
### 🙌 Improvements
* Enable Login with QR code in release builds. by @bmarty in https://github.com/element-hq/element-x-android/pull/3646
* Remove unused `RoomSummary` cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3647
### 🐛 Bugfixes
* Add the `CallWebView` logs to our logging stack by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3637
### Dependency upgrades
* Update dependency io.element.android:emojibase-bindings to v1.3.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3620
* fix(deps): update dependency androidx.compose:compose-bom to v2024.09.03 by @renovate in https://github.com/element-hq/element-x-android/pull/3583
* fix(deps): update dependency io.mockk:mockk to v1.13.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3634
* chore(deps): update dependencyanalysis to v2.1.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3610
* fix(deps): update dependency androidx.webkit:webkit to v1.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3584
* fix(deps): update dependency com.posthog:posthog-android to v3.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3638
* Upgrade Kotlin to v2.0 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3594
### Others
* Rework room summary by @ganfra in https://github.com/element-hq/element-x-android/pull/3631
* QrCode intro screen: add subtitle and fix button wording #3632 by @bmarty in https://github.com/element-hq/element-x-android/pull/3633
* Improve avatar rendering by @ganfra in https://github.com/element-hq/element-x-android/pull/3642
* Add feature flag IdentityPinningViolationNotifications. by @bmarty in https://github.com/element-hq/element-x-android/pull/3648
* Crypto copy adjustment by @bmarty in https://github.com/element-hq/element-x-android/pull/3649
Changes in Element X v0.6.5 (2024-10-09) Changes in Element X v0.6.5 (2024-10-09)
======================================== ========================================

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Αποσύνδεση &amp;amp; Αναβάθμιση"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Αποσύνδεση &amp; Αναβάθμιση"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."</string>
</resources> </resources>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйти и обновить"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Выйти и обновить"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш homeserver больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."</string>
</resources> </resources>

View file

@ -49,7 +49,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.15") detektPlugins("io.nlopez.compose.rules:detekt:0.4.16")
} }
// KtLint // KtLint

View file

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

View file

@ -2,7 +2,7 @@ Element X brings you both sovereign & seamless collaboration built on Matrix.
The collaboration capabilities include chat & video calls with the modern set of features such as: The collaboration capabilities include chat & video calls with the modern set of features such as:
• public & private channels • public & private channels
• room moderation & access conUpdatetrol • room moderation & access control
• replies, reactions, polls, read receipts, pinned messages, etc. • replies, reactions, polls, read receipts, pinned messages, etc.
• simultaneous chat & calls (picture in picture) • simultaneous chat & calls (picture in picture)
• decentralized & federated communication across organizations • decentralized & federated communication across organizations
@ -32,4 +32,8 @@ Enjoy the freedom of the Matrix open standard! You have native interoperability
Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages. Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages.
<b>Chat across multiple devices</b> <b>Chat across multiple devices</b>
Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
The application requires the USE_FULL_SCREEN_INTENT permission to ensure our users can effectively receive call notifications even when their devices are locked.

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="screen_analytics_settings_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string> <string name="screen_analytics_settings_help_us_improve">"Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее."</string>
<string name="screen_analytics_settings_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string> <string name="screen_analytics_settings_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"здесь"</string> <string name="screen_analytics_settings_read_terms_content_link">"здесь"</string>
<string name="screen_analytics_settings_share_data">"Делитесь данными аналитики"</string> <string name="screen_analytics_settings_share_data">"Отправлять аналитические данные"</string>
</resources> </resources>

View file

@ -4,7 +4,7 @@
<string name="screen_analytics_prompt_help_us_improve">"Partilhe dados de utilização anónimos para nos ajudar a identificar problemas."</string> <string name="screen_analytics_prompt_help_us_improve">"Partilhe dados de utilização anónimos para nos ajudar a identificar problemas."</string>
<string name="screen_analytics_prompt_read_terms">"Podes ler todos os nossos termos %1$s."</string> <string name="screen_analytics_prompt_read_terms">"Podes ler todos os nossos termos %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aqui"</string> <string name="screen_analytics_prompt_read_terms_content_link">"aqui"</string>
<string name="screen_analytics_prompt_settings">"Podes desligar qualquer momento"</string> <string name="screen_analytics_prompt_settings">"Pode desactivar a qualquer momento"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Não partilharemos os teus dados com terceiros"</string> <string name="screen_analytics_prompt_third_party_sharing">"Não partilharemos os teus dados com terceiros"</string>
<string name="screen_analytics_prompt_title">"Ajude a melhorar a %1$s"</string> <string name="screen_analytics_prompt_title">"Ajude a melhorar a %1$s"</string>
</resources> </resources>

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="screen_analytics_prompt_data_usage">"Мы не будем записывать или профилировать какие-либо персональные данные"</string> <string name="screen_analytics_prompt_data_usage">"Мы не будем записывать или профилировать какие-либо персональные данные"</string>
<string name="screen_analytics_prompt_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string> <string name="screen_analytics_prompt_help_us_improve">"Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее."</string>
<string name="screen_analytics_prompt_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string> <string name="screen_analytics_prompt_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"здесь"</string> <string name="screen_analytics_prompt_read_terms_content_link">"здесь"</string>
<string name="screen_analytics_prompt_settings">"Вы можете отключить эту функцию в любое время"</string> <string name="screen_analytics_prompt_settings">"Вы можете отключить эту функцию в любое время"</string>

View file

@ -21,8 +21,8 @@
<!-- Permissions for call foreground services --> <!-- Permissions for call foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application> <application>
@ -80,7 +80,7 @@
android:name=".services.CallForegroundService" android:name=".services.CallForegroundService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="phoneCall" /> android:foregroundServiceType="microphone" />
<receiver <receiver
android:name=".receivers.DeclineCallBroadcastReceiver" android:name=".receivers.DeclineCallBroadcastReceiver"

View file

@ -7,9 +7,11 @@
package io.element.android.features.call.impl.services package io.element.android.features.call.impl.services
import android.Manifest
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
@ -33,8 +35,12 @@ import timber.log.Timber
class CallForegroundService : Service() { class CallForegroundService : Service() {
companion object { companion object {
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, CallForegroundService::class.java) if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
ContextCompat.startForegroundService(context, intent) val intent = Intent(context, CallForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
Timber.w("Microphone permission is not granted, cannot start the call foreground service")
}
} }
fun stop(context: Context) { fun stop(context: Context) {
@ -67,8 +73,8 @@ class CallForegroundService : Service() {
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .build()
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL) val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else { } else {
0 0
} }

View file

@ -180,6 +180,7 @@ class CallScreenPresenter @AssistedInject constructor(
urlState = urlState.value, urlState = urlState.value,
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isJoinedCall,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )

View file

@ -13,6 +13,7 @@ data class CallScreenState(
val urlState: AsyncData<String>, val urlState: AsyncData<String>,
val webViewError: String?, val webViewError: String?,
val userAgent: String, val userAgent: String,
val isCallActive: Boolean,
val isInWidgetMode: Boolean, val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit, val eventSink: (CallScreenEvents) -> Unit,
) )

View file

@ -24,6 +24,7 @@ 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, webViewError: String? = null,
userAgent: String = "", userAgent: String = "",
isCallActive: Boolean = true,
isInWidgetMode: Boolean = false, isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {}, eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState { ): CallScreenState {
@ -31,6 +32,7 @@ internal fun aCallScreenState(
urlState = urlState, urlState = urlState,
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -26,6 +26,7 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@ -95,7 +96,6 @@ class ElementCallActivity :
pictureInPicturePresenter.setPipView(this) pictureInPicturePresenter.setPipView(this)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus()
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
@ -103,6 +103,12 @@ class ElementCallActivity :
ElementThemeApp(appPreferencesStore) { ElementThemeApp(appPreferencesStore) {
val state = presenter.present() val state = presenter.present()
eventSink = state.eventSink eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
if (state.isCallActive || !state.isInWidgetMode) {
setCallIsActive()
}
}
CallScreenView( CallScreenView(
state = state, state = state,
pipState = pipState, pipState = pipState,
@ -115,6 +121,11 @@ class ElementCallActivity :
} }
} }
private fun setCallIsActive() {
requestAudioFocus()
CallForegroundService.start(this)
}
@Composable @Composable
private fun ListenToAndroidEvents(pipState: PictureInPictureState) { private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
val pipEventSink by rememberUpdatedState(pipState.eventSink) val pipEventSink by rememberUpdatedState(pipState.eventSink)
@ -156,18 +167,6 @@ class ElementCallActivity :
setCallType(intent) setCallType(intent)
} }
override fun onStart() {
super.onStart()
CallForegroundService.stop(this)
}
override fun onStop() {
super.onStop()
if (!isFinishing && !isChangingConfigurations) {
CallForegroundService.start(this)
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
releaseAudioFocus() releaseAudioFocus()
@ -231,10 +230,10 @@ class ElementCallActivity :
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun requestAudioFocus() { private fun requestAudioFocus() {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes) .setAudioAttributes(audioAttributes)
.build() .build()
@ -247,7 +246,6 @@ class ElementCallActivity :
AudioManager.STREAM_VOICE_CALL, AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
) )
audioFocusChangeListener = listener audioFocusChangeListener = listener
} }
} }

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Actieve oproep"</string> <string name="call_foreground_service_channel_title_android">"Actieve oproep"</string>
<string name="call_foreground_service_message_android">"Tik om terug te gaan naar het gesprek"</string> <string name="call_foreground_service_message_android">"Tik om terug te gaan naar het gesprek"</string>
<string name="call_foreground_service_title_android">"☎️ In gesprek"</string> <string name="call_foreground_service_title_android">"☎️ In gesprek"</string>
<string name="screen_incoming_call_subtitle_android">"Inkomende Element-oproep"</string>
</resources> </resources>

View file

@ -73,6 +73,7 @@ class CallScreenPresenterTest {
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.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse() assertThat(initialState.isInWidgetMode).isFalse()
assertThat(initialState.isCallActive).isFalse()
analyticsLambda.assertions().isNeverCalled() analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
} }
@ -106,6 +107,7 @@ class CallScreenPresenterTest {
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue() assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1) assertThat(widgetDriver.runCalledCount).isEqualTo(1)
@ -203,6 +205,44 @@ class CallScreenPresenterTest {
} }
} }
@Test
fun `present - a received room member message makes the call to be active`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage(
"""
{
"action":"send_event",
"api":"fromWidget",
"widgetId":"1",
"requestId":"1",
"data":{
"type":"org.matrix.msc3401.call.member"
}
}
""".trimIndent()
)
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue()
}
}
@Test @Test
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Новая комната"</string> <string name="screen_create_room_action_create_room">"Создать новую комнату"</string>
<string name="screen_create_room_add_people_title">"Пригласить в комнату"</string> <string name="screen_create_room_add_people_title">"Пригласить в комнату"</string>
<string name="screen_create_room_error_creating_room">"Произошла ошибка при создании комнаты"</string> <string name="screen_create_room_error_creating_room">"Произошла ошибка при создании комнаты"</string>
<string name="screen_create_room_private_option_description">"Сообщения в этой комнате зашифрованы. Отключить шифрование позже будет невозможно."</string> <string name="screen_create_room_private_option_description">"Сообщения в этой комнате будут зашифрованы. Отключить шифрование позже будет невозможно."</string>
<string name="screen_create_room_private_option_title">"Приватная комната (только по приглашению)"</string> <string name="screen_create_room_private_option_title">"Частная комната (только по приглашениям)"</string>
<string name="screen_create_room_public_option_description">"Сообщения не зашифрованы, каждый может их прочитать. Вы можете включить шифрование позже."</string> <string name="screen_create_room_public_option_description">"Сообщения не будут зашифрованы и каждый сможет их прочитать. Шифрование можно будет включить позже."</string>
<string name="screen_create_room_public_option_title">"Публичная комната (любой)"</string> <string name="screen_create_room_public_option_title">"Общедоступная комната (для всех)"</string>
<string name="screen_create_room_room_name_label">"Название комнаты"</string> <string name="screen_create_room_room_name_label">"Название комнаты"</string>
<string name="screen_create_room_title">"Создать комнату"</string> <string name="screen_create_room_title">"Создать комнату"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string> <string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при попытке открытия комнаты"</string> <string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string>
</resources> </resources>

View file

@ -51,7 +51,7 @@ class AccountDeactivationPresenter @Inject constructor(
action action
) )
} else { } else {
action.value = AsyncAction.Confirming action.value = AsyncAction.ConfirmingNoParams
} }
AccountDeactivationEvents.CloseDialogs -> { AccountDeactivationEvents.CloseDialogs -> {
action.value = AsyncAction.Uninitialized action.value = AsyncAction.Uninitialized

View file

@ -20,7 +20,7 @@ open class AccountDeactivationStateProvider : PreviewParameterProvider<AccountDe
), ),
anAccountDeactivationState( anAccountDeactivationState(
deactivateFormState = filledForm, deactivateFormState = filledForm,
accountDeactivationAction = AsyncAction.Confirming, accountDeactivationAction = AsyncAction.ConfirmingNoParams,
), ),
anAccountDeactivationState( anAccountDeactivationState(
deactivateFormState = filledForm, deactivateFormState = filledForm,

View file

@ -24,7 +24,7 @@ fun AccountDeactivationActionDialog(
when (state) { when (state) {
AsyncAction.Uninitialized -> AsyncAction.Uninitialized ->
Unit Unit
AsyncAction.Confirming -> is AsyncAction.Confirming ->
AccountDeactivationConfirmationDialog( AccountDeactivationConfirmationDialog(
onSubmitClick = onConfirmClick, onSubmitClick = onConfirmClick,
onDismiss = onDismissDialog onDismiss = onDismissDialog

View file

@ -1,4 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_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_bold_part">"незваротны"</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_title">"Дэактываваць уліковы запіс"</string> <string name="screen_deactivate_account_title">"Дэактываваць уліковы запіс"</string>
</resources> </resources>

View file

@ -74,7 +74,7 @@ class AccountDeactivationPresenterTest {
skipItems(1) skipItems(1)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
@ -102,7 +102,7 @@ class AccountDeactivationPresenterTest {
skipItems(2) skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
@ -135,7 +135,7 @@ class AccountDeactivationPresenterTest {
skipItems(2) skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)

View file

@ -71,7 +71,7 @@ class AccountDeactivationViewTest {
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
password = A_PASSWORD, password = A_PASSWORD,
), ),
accountDeactivationAction = AsyncAction.Confirming, accountDeactivationAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )

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="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string> <string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string> <string name="screen_notification_optin_title">"Разрешите отправку уведомлений и ни одно сообщение не будет пропущено"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string> <string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string> <string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -9,10 +9,8 @@ package io.element.android.features.invite.api.response
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import java.util.Optional
data class AcceptDeclineInviteState( data class AcceptDeclineInviteState(
val invite: Optional<InviteData>,
val acceptAction: AsyncAction<RoomId>, val acceptAction: AsyncAction<RoomId>,
val declineAction: AsyncAction<RoomId>, val declineAction: AsyncAction<RoomId>,
val eventSink: (AcceptDeclineInviteEvents) -> Unit, val eventSink: (AcceptDeclineInviteEvents) -> Unit,

View file

@ -10,23 +10,20 @@ package io.element.android.features.invite.api.response
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import java.util.Optional
open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> { open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> {
override val values: Sequence<AcceptDeclineInviteState> override val values: Sequence<AcceptDeclineInviteState>
get() = sequenceOf( get() = sequenceOf(
anAcceptDeclineInviteState(), anAcceptDeclineInviteState(),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
invite = Optional.of( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = true, roomName = "Alice"), InviteData(RoomId("!room:matrix.org"), isDm = true, roomName = "Alice")
), ),
declineAction = AsyncAction.Confirming,
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
invite = Optional.of( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = false, roomName = "Some room"), InviteData(RoomId("!room:matrix.org"), isDm = false, roomName = "Some room")
), ),
declineAction = AsyncAction.Confirming,
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
acceptAction = AsyncAction.Failure(Throwable("Whoops")), acceptAction = AsyncAction.Failure(Throwable("Whoops")),
@ -38,12 +35,10 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
} }
fun anAcceptDeclineInviteState( fun anAcceptDeclineInviteState(
invite: Optional<InviteData> = Optional.empty(),
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {} eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState( ) = AcceptDeclineInviteState(
invite = invite,
acceptAction = acceptAction, acceptAction = acceptAction,
declineAction = declineAction, declineAction = declineAction,
eventSink = eventSink, eventSink = eventSink,

View file

@ -0,0 +1,14 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.invite.api.response
import io.element.android.libraries.architecture.AsyncAction
data class ConfirmingDeclineInvite(
val inviteData: InviteData,
) : AsyncAction.Confirming

View file

@ -9,15 +9,13 @@ package io.element.android.features.invite.impl.response
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -29,9 +27,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Optional
import javax.inject.Inject import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
class AcceptDeclineInvitePresenter @Inject constructor( class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
@ -43,35 +39,22 @@ class AcceptDeclineInvitePresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val acceptedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val acceptedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val declinedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val declinedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var currentInvite by remember {
mutableStateOf<Optional<InviteData>>(Optional.empty())
}
fun handleEvents(event: AcceptDeclineInviteEvents) { fun handleEvents(event: AcceptDeclineInviteEvents) {
when (event) { when (event) {
is AcceptDeclineInviteEvents.AcceptInvite -> { is AcceptDeclineInviteEvents.AcceptInvite -> {
// currentInvite is used to render the decline confirmation dialog
// and to reuse the roomId when the user confirm the rejection of the invitation.
// Just set it to empty here.
currentInvite = Optional.empty()
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction)
} }
is AcceptDeclineInviteEvents.DeclineInvite -> { is AcceptDeclineInviteEvents.DeclineInvite -> {
currentInvite = Optional.of(event.invite) declinedAction.value = ConfirmingDeclineInvite(event.invite)
declinedAction.value = AsyncAction.Confirming
} }
is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> { is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> {
declinedAction.value = AsyncAction.Uninitialized localCoroutineScope.declineInvite(event.roomId, declinedAction)
currentInvite.getOrNull()?.let {
localCoroutineScope.declineInvite(it.roomId, declinedAction)
}
currentInvite = Optional.empty()
} }
is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> { is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
currentInvite = Optional.empty()
declinedAction.value = AsyncAction.Uninitialized declinedAction.value = AsyncAction.Uninitialized
} }
@ -86,7 +69,6 @@ class AcceptDeclineInvitePresenter @Inject constructor(
} }
return AcceptDeclineInviteState( return AcceptDeclineInviteState(
invite = currentInvite,
acceptAction = acceptedAction.value, acceptAction = acceptedAction.value,
declineAction = declinedAction.value, declineAction = declinedAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
@ -112,8 +94,8 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<AsyncAction<RoomId>>) = launch { private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<AsyncAction<RoomId>>) = launch {
suspend { suspend {
client.getInvitedRoom(roomId)?.use { client.getPendingRoom(roomId)?.use {
it.declineInvite().getOrThrow() it.leave().getOrThrow()
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
roomId roomId

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.invite.impl.R import io.element.android.features.invite.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -22,7 +23,6 @@ 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.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.jvm.optionals.getOrNull
@Composable @Composable
fun AcceptDeclineInviteView( fun AcceptDeclineInviteView(
@ -45,13 +45,13 @@ fun AcceptDeclineInviteView(
onErrorDismiss = { onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError) state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError)
}, },
confirmationDialog = { confirmationDialog = { confirming ->
val invite = state.invite.getOrNull() // Note: confirming will always be of type ConfirmingDeclineInvite.
if (invite != null) { if (confirming is ConfirmingDeclineInvite) {
DeclineConfirmationDialog( DeclineConfirmationDialog(
invite = invite, invite = confirming.inviteData,
onConfirmClick = { onConfirmClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite) state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(confirming.inviteData.roomId))
}, },
onDismissClick = { onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)

View file

@ -8,9 +8,10 @@
package io.element.android.features.invite.impl.response package io.element.android.features.invite.impl.response
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data object ConfirmDeclineInvite : InternalAcceptDeclineInviteEvents data class ConfirmDeclineInvite(val roomId: RoomId) : InternalAcceptDeclineInviteEvents
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents data object DismissAcceptError : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents data object DismissDeclineError : InternalAcceptDeclineInviteEvents

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para %1$s?"</string> <string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar conite"</string> <string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string> <string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string> <string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>

View file

@ -10,6 +10,7 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -21,7 +22,7 @@ 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_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeInvitedRoom import io.element.android.libraries.matrix.test.room.FakePendingRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
@ -33,7 +34,6 @@ import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Optional
class AcceptDeclineInvitePresenterTest { class AcceptDeclineInvitePresenterTest {
@get:Rule @get:Rule
@ -46,7 +46,6 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
} }
} }
} }
@ -61,17 +60,13 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(inviteData)
) )
} }
skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.of(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
assertThat(state.declineAction).isInstanceOf(AsyncAction.Confirming::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.CancelDeclineInvite InternalAcceptDeclineInviteEvents.CancelDeclineInvite
) )
} }
skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
@ -83,7 +78,7 @@ class AcceptDeclineInvitePresenterTest {
Result.failure<Unit>(RuntimeException("Failed to leave room")) Result.failure<Unit>(RuntimeException("Failed to leave room"))
} }
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
getInvitedRoomResults[A_ROOM_ID] = FakeInvitedRoom(declineInviteResult = declineInviteFailure) getPendingRoomResults[A_ROOM_ID] = FakePendingRoom(declineInviteResult = declineInviteFailure)
} }
val presenter = createAcceptDeclineInvitePresenter(client = client) val presenter = createAcceptDeclineInvitePresenter(client = client)
presenter.test { presenter.test {
@ -93,22 +88,20 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(inviteData)
) )
} }
skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId)
) )
} }
skipItems(2) assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.DismissDeclineError InternalAcceptDeclineInviteEvents.DismissDeclineError
) )
} }
skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
@ -128,7 +121,7 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit) Result.success(Unit)
} }
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
getInvitedRoomResults[A_ROOM_ID] = FakeInvitedRoom(declineInviteResult = declineInviteSuccess) getPendingRoomResults[A_ROOM_ID] = FakePendingRoom(declineInviteResult = declineInviteSuccess)
} }
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
client = client, client = client,
@ -141,13 +134,13 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(inviteData)
) )
} }
skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId)
) )
} }
skipItems(2) assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
} }
@ -173,7 +166,6 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
} }
awaitItem().also { state -> awaitItem().also { state ->
@ -183,7 +175,6 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
@ -220,7 +211,6 @@ class AcceptDeclineInvitePresenterTest {
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
} }
awaitItem().also { state -> awaitItem().also { state ->

View file

@ -11,7 +11,9 @@ sealed interface JoinRoomEvents {
data object RetryFetchingContent : JoinRoomEvents data object RetryFetchingContent : JoinRoomEvents
data object JoinRoom : JoinRoomEvents data object JoinRoom : JoinRoomEvents
data object KnockRoom : JoinRoomEvents data object KnockRoom : JoinRoomEvents
data object ClearError : JoinRoomEvents data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvents
data class UpdateKnockMessage(val message: String) : JoinRoomEvents
data object ClearActionStates : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents data object DeclineInvite : JoinRoomEvents
} }

View file

@ -43,7 +43,8 @@ class JoinRoomNode @AssistedInject constructor(
state = state, state = state,
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp, onJoinSuccess = ::navigateUp,
onKnockSuccess = ::navigateUp, onCancelKnockSuccess = ::navigateUp,
onKnockSuccess = { },
modifier = modifier modifier = modifier
) )
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -24,6 +25,7 @@ import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -46,6 +48,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Optional import java.util.Optional
private const val MAX_KNOCK_MESSAGE_LENGTH = 500
class JoinRoomPresenter @AssistedInject constructor( class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId, @Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias, @Assisted private val roomIdOrAlias: RoomIdOrAlias,
@ -55,6 +59,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val joinRoom: JoinRoom, private val joinRoom: JoinRoom,
private val knockRoom: KnockRoom, private val knockRoom: KnockRoom,
private val cancelKnockRoom: CancelKnockRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
@ -75,6 +80,8 @@ class JoinRoomPresenter @AssistedInject constructor(
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty()) val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var knockMessage by rememberSaveable { mutableStateOf("") }
val contentState by produceState<ContentState>( val contentState by produceState<ContentState>(
initialValue = ContentState.Loading(roomIdOrAlias), initialValue = ContentState.Loading(roomIdOrAlias),
key1 = roomInfo, key1 = roomInfo,
@ -110,7 +117,7 @@ class JoinRoomPresenter @AssistedInject constructor(
fun handleEvents(event: JoinRoomEvents) { fun handleEvents(event: JoinRoomEvents) {
when (event) { when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction) is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage)
JoinRoomEvents.AcceptInvite -> { JoinRoomEvents.AcceptInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
@ -123,12 +130,17 @@ class JoinRoomPresenter @AssistedInject constructor(
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(inviteData)
) )
} }
is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction)
JoinRoomEvents.RetryFetchingContent -> { JoinRoomEvents.RetryFetchingContent -> {
retryCount++ retryCount++
} }
JoinRoomEvents.ClearError -> { JoinRoomEvents.ClearActionStates -> {
knockAction.value = AsyncAction.Uninitialized knockAction.value = AsyncAction.Uninitialized
joinAction.value = AsyncAction.Uninitialized joinAction.value = AsyncAction.Uninitialized
cancelKnockAction.value = AsyncAction.Uninitialized
}
is JoinRoomEvents.UpdateKnockMessage -> {
knockMessage = event.message.take(MAX_KNOCK_MESSAGE_LENGTH)
} }
} }
} }
@ -138,7 +150,9 @@ class JoinRoomPresenter @AssistedInject constructor(
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
joinAction = joinAction.value, joinAction = joinAction.value,
knockAction = knockAction.value, knockAction = knockAction.value,
cancelKnockAction = cancelKnockAction.value,
applicationName = buildMeta.applicationName, applicationName = buildMeta.applicationName,
knockMessage = knockMessage,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -153,9 +167,19 @@ class JoinRoomPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.knockRoom(knockAction: MutableState<AsyncAction<Unit>>) = launch { private fun CoroutineScope.knockRoom(knockAction: MutableState<AsyncAction<Unit>>, message: String) = launch {
knockAction.runUpdatingState { knockAction.runUpdatingState {
knockRoom(roomId) knockRoom(roomIdOrAlias, message, serverNames)
}
}
private fun CoroutineScope.cancelKnockRoom(requiresConfirmation: Boolean, cancelKnockAction: MutableState<AsyncAction<Unit>>) = launch {
if (requiresConfirmation) {
cancelKnockAction.value = AsyncAction.ConfirmingNoParams
} else {
cancelKnockAction.runUpdatingState {
cancelKnockRoom(roomId)
}
} }
} }
} }
@ -206,7 +230,7 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
name = name, name = name,
topic = topic, topic = topic,
alias = canonicalAlias, alias = canonicalAlias,
numberOfMembers = activeMembersCount.toLong(), numberOfMembers = activeMembersCount,
isDm = isDm, isDm = isDm,
roomType = if (isSpace) RoomType.Space else RoomType.Room, roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl, roomAvatarUrl = avatarUrl,
@ -214,6 +238,7 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited( currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteSender = inviter?.toInviteSender() inviteSender = inviter?.toInviteSender()
) )
currentUserMembership == CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
isPublic -> JoinAuthorisationStatus.CanJoin isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown else -> JoinAuthorisationStatus.Unknown
} }

View file

@ -24,7 +24,9 @@ data class JoinRoomState(
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,
val joinAction: AsyncAction<Unit>, val joinAction: AsyncAction<Unit>,
val knockAction: AsyncAction<Unit>, val knockAction: AsyncAction<Unit>,
val cancelKnockAction: AsyncAction<Unit>,
val applicationName: String, val applicationName: String,
val knockMessage: String,
val eventSink: (JoinRoomEvents) -> Unit val eventSink: (JoinRoomEvents) -> Unit
) { ) {
val joinAuthorisationStatus = when (contentState) { val joinAuthorisationStatus = when (contentState) {
@ -68,6 +70,7 @@ sealed interface ContentState {
sealed interface JoinAuthorisationStatus { sealed interface JoinAuthorisationStatus {
data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
data object IsKnocked : JoinAuthorisationStatus
data object CanKnock : JoinAuthorisationStatus data object CanKnock : JoinAuthorisationStatus
data object CanJoin : JoinAuthorisationStatus data object CanJoin : JoinAuthorisationStatus
data object Unknown : JoinAuthorisationStatus data object Unknown : JoinAuthorisationStatus

View file

@ -81,6 +81,12 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
isDm = true, isDm = true,
) )
), ),
aJoinRoomState(
contentState = aLoadedContentState(
name = "A knocked Room",
joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked
)
)
) )
} }
@ -124,13 +130,17 @@ fun aJoinRoomState(
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
joinAction: AsyncAction<Unit> = AsyncAction.Uninitialized, joinAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized, knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cancelKnockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
knockMessage: String = "",
eventSink: (JoinRoomEvents) -> Unit = {} eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState( ) = JoinRoomState(
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
joinAction = joinAction, joinAction = joinAction,
knockAction = knockAction, knockAction = knockAction,
cancelKnockAction = cancelKnockAction,
applicationName = "AppName", applicationName = "AppName",
knockMessage = knockMessage,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -9,21 +9,31 @@ package io.element.android.features.joinroom.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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 io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -32,20 +42,25 @@ import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescrip
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
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.LightGradientBackground import io.element.android.libraries.designsystem.background.LightGradientBackground
import io.element.android.libraries.designsystem.components.BigIcon
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.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.SuperButton import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text 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.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
@ -59,6 +74,7 @@ fun JoinRoomView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onJoinSuccess: () -> Unit, onJoinSuccess: () -> Unit,
onKnockSuccess: () -> Unit, onKnockSuccess: () -> Unit,
onCancelKnockSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
@ -69,12 +85,14 @@ fun JoinRoomView(
containerColor = Color.Transparent, containerColor = Color.Transparent,
paddingValues = PaddingValues(16.dp), paddingValues = PaddingValues(16.dp),
topBar = { topBar = {
JoinRoomTopBar(onBackClick = onBackClick) JoinRoomTopBar(contentState = state.contentState, onBackClick = onBackClick)
}, },
content = { content = {
JoinRoomContent( JoinRoomContent(
contentState = state.contentState, contentState = state.contentState,
applicationName = state.applicationName, applicationName = state.applicationName,
knockMessage = state.knockMessage,
onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) },
) )
}, },
footer = { footer = {
@ -92,6 +110,9 @@ fun JoinRoomView(
onKnockRoom = { onKnockRoom = {
state.eventSink(JoinRoomEvents.KnockRoom) state.eventSink(JoinRoomEvents.KnockRoom)
}, },
onCancelKnock = {
state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = true))
},
onRetry = { onRetry = {
state.eventSink(JoinRoomEvents.RetryFetchingContent) state.eventSink(JoinRoomEvents.RetryFetchingContent)
}, },
@ -103,12 +124,30 @@ fun JoinRoomView(
AsyncActionView( AsyncActionView(
async = state.joinAction, async = state.joinAction,
onSuccess = { onJoinSuccess() }, onSuccess = { onJoinSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) }, onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) },
) )
AsyncActionView( AsyncActionView(
async = state.knockAction, async = state.knockAction,
onSuccess = { onKnockSuccess() }, onSuccess = { onKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) }, onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) },
)
AsyncActionView(
async = state.cancelKnockAction,
onSuccess = { onCancelKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) },
errorMessage = {
stringResource(CommonStrings.error_unknown)
},
confirmationDialog = {
ConfirmationDialog(
content = stringResource(R.string.screen_join_room_cancel_knock_alert_description),
title = stringResource(R.string.screen_join_room_cancel_knock_alert_title),
submitText = stringResource(R.string.screen_join_room_cancel_knock_alert_confirmation),
cancelText = stringResource(CommonStrings.action_no),
onSubmitClick = { state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = false)) },
onDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) },
)
},
) )
} }
@ -119,63 +158,81 @@ private fun JoinRoomFooter(
onDeclineInvite: () -> Unit, onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit, onKnockRoom: () -> Unit,
onCancelKnock: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onGoBack: () -> Unit, onGoBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.contentState is ContentState.Failure) { Box(
Button( modifier = modifier
text = stringResource(CommonStrings.action_retry), .fillMaxWidth()
onClick = onRetry, .padding(top = 8.dp)
modifier = modifier.fillMaxWidth(), ) {
size = ButtonSize.Large, if (state.contentState is ContentState.Failure) {
) Button(
} else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) { text = stringResource(CommonStrings.action_retry),
Button( onClick = onRetry,
text = stringResource(CommonStrings.action_go_back), modifier = Modifier.fillMaxWidth(),
onClick = onGoBack, size = ButtonSize.Large,
modifier = modifier.fillMaxWidth(), )
size = ButtonSize.Large, } else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) {
) Button(
} else { text = stringResource(CommonStrings.action_go_back),
val joinAuthorisationStatus = state.joinAuthorisationStatus onClick = onGoBack,
when (joinAuthorisationStatus) { modifier = Modifier.fillMaxWidth(),
is JoinAuthorisationStatus.IsInvited -> { size = ButtonSize.Large,
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { )
} else {
val joinAuthorisationStatus = state.joinAuthorisationStatus
when (joinAuthorisationStatus) {
is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineInvite,
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite,
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
}
}
JoinAuthorisationStatus.CanJoin -> {
SuperButton(
onClick = onJoinRoom,
modifier = Modifier.fillMaxWidth(),
buttonSize = ButtonSize.Large,
) {
Text(
text = stringResource(R.string.screen_join_room_join_action),
)
}
}
JoinAuthorisationStatus.CanKnock -> {
SuperButton(
onClick = onKnockRoom,
modifier = Modifier.fillMaxWidth(),
buttonSize = ButtonSize.Large,
) {
Text(
text = stringResource(R.string.screen_join_room_knock_action),
)
}
}
JoinAuthorisationStatus.IsKnocked -> {
OutlinedButton( OutlinedButton(
text = stringResource(CommonStrings.action_decline), text = stringResource(R.string.screen_join_room_cancel_knock_action),
onClick = onDeclineInvite, onClick = onCancelKnock,
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
size = ButtonSize.LargeLowPadding, size = ButtonSize.Large,
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite,
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
) )
} }
JoinAuthorisationStatus.Unknown -> Unit
} }
JoinAuthorisationStatus.CanJoin -> {
SuperButton(
onClick = onJoinRoom,
modifier = modifier.fillMaxWidth(),
buttonSize = ButtonSize.Large,
) {
Text(
text = stringResource(R.string.screen_join_room_join_action),
)
}
}
JoinAuthorisationStatus.CanKnock -> {
Button(
text = stringResource(R.string.screen_join_room_knock_action),
onClick = onKnockRoom,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Large,
)
}
JoinAuthorisationStatus.Unknown -> Unit
} }
} }
} }
@ -184,132 +241,217 @@ private fun JoinRoomFooter(
private fun JoinRoomContent( private fun JoinRoomContent(
contentState: ContentState, contentState: ContentState,
applicationName: String, applicationName: String,
knockMessage: String,
onKnockMessageUpdate: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (contentState) { Box(modifier = modifier) {
is ContentState.Loaded -> { when (contentState) {
RoomPreviewOrganism( is ContentState.Loaded -> {
modifier = modifier, when (contentState.joinAuthorisationStatus) {
avatar = { is JoinAuthorisationStatus.IsKnocked -> {
Avatar(contentState.avatarData(AvatarSize.RoomHeader)) IsKnockedLoadedContent()
}, }
title = { else -> {
if (contentState.name != null) { DefaultLoadedContent(
RoomPreviewTitleAtom( modifier = Modifier.verticalScroll(rememberScrollState()),
title = contentState.name, contentState = contentState,
applicationName = applicationName,
knockMessage = knockMessage,
onKnockMessageUpdate = onKnockMessageUpdate
) )
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic
)
}
},
subtitle = {
if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
},
description = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender)
}
RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.roomType == RoomType.Space) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_title),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
},
memberCount = {
if (contentState.showMemberCount) {
RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
} }
} }
) }
} is ContentState.UnknownRoom -> {
is ContentState.UnknownRoom -> { RoomPreviewOrganism(
RoomPreviewOrganism( avatar = {
modifier = modifier, PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
avatar = { },
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) title = {
}, RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
title = { },
RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview)) subtitle = {
}, RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview))
subtitle = { },
RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview)) )
}, }
) is ContentState.Loading -> {
} RoomPreviewOrganism(
is ContentState.Loading -> { avatar = {
RoomPreviewOrganism( PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
modifier = modifier, },
avatar = { title = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) PlaceholderAtom(width = 200.dp, height = 22.dp)
}, },
title = { subtitle = {
PlaceholderAtom(width = 200.dp, height = 22.dp) PlaceholderAtom(width = 140.dp, height = 20.dp)
}, },
subtitle = { )
PlaceholderAtom(width = 140.dp, height = 20.dp) }
}, is ContentState.Failure -> {
) RoomPreviewOrganism(
} avatar = {
is ContentState.Failure -> { PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
RoomPreviewOrganism( },
modifier = modifier, title = {
avatar = { when (contentState.roomIdOrAlias) {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) is RoomIdOrAlias.Alias -> {
}, RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier)
title = { }
when (contentState.roomIdOrAlias) { is RoomIdOrAlias.Id -> {
is RoomIdOrAlias.Alias -> { PlaceholderAtom(width = 200.dp, height = 22.dp)
RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier) }
} }
is RoomIdOrAlias.Id -> { },
PlaceholderAtom(width = 200.dp, height = 22.dp) subtitle = {
} Text(
} text = stringResource(id = CommonStrings.error_unknown),
}, textAlign = TextAlign.Center,
subtitle = { color = MaterialTheme.colorScheme.error,
Text( )
text = stringResource(id = CommonStrings.error_unknown), },
textAlign = TextAlign.Center, )
color = MaterialTheme.colorScheme.error, }
)
},
)
} }
} }
} }
@Composable
private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) {
BoxWithConstraints(
modifier = modifier
.fillMaxHeight()
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
IconTitleSubtitleMolecule(
modifier = Modifier.sizeIn(minHeight = maxHeight * 0.7f),
iconStyle = BigIcon.Style.SuccessSolid,
title = stringResource(R.string.screen_join_room_knock_sent_title),
subTitle = stringResource(R.string.screen_join_room_knock_sent_description),
)
}
}
@Composable
private fun DefaultLoadedContent(
contentState: ContentState.Loaded,
applicationName: String,
knockMessage: String,
onKnockMessageUpdate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
RoomPreviewOrganism(
modifier = modifier,
avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
},
title = {
if (contentState.name != null) {
RoomPreviewTitleAtom(
title = contentState.name,
)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic
)
}
},
subtitle = {
if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
},
description = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender)
}
RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.roomType == RoomType.Space) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_title),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
} else if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) {
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = knockMessage,
onValueChange = onKnockMessageUpdate,
maxLines = 3,
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.screen_join_room_knock_message_description),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPlaceholder,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
},
memberCount = {
if (contentState.showMemberCount) {
RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun JoinRoomTopBar( private fun JoinRoomTopBar(
contentState: ContentState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
BackButton(onClick = onBackClick) BackButton(onClick = onBackClick)
}, },
title = {}, title = {
if (contentState is ContentState.Loaded && contentState.joinAuthorisationStatus is JoinAuthorisationStatus.IsKnocked) {
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
if (contentState.name != null) {
Row(
modifier = titleModifier,
verticalAlignment = Alignment.CenterVertically
) {
Avatar(avatarData = contentState.avatarData(AvatarSize.TimelineRoom))
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = contentState.name,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
}
}
},
) )
} }
@ -321,5 +463,6 @@ internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class)
onBackClick = { }, onBackClick = { },
onJoinSuccess = { }, onJoinSuccess = { },
onKnockSuccess = { }, onKnockSuccess = { },
onCancelKnockSuccess = { },
) )
} }

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.joinroom.impl.di
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
interface CancelKnockRoom {
suspend operator fun invoke(roomId: RoomId): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultCancelKnockRoom @Inject constructor(private val client: MatrixClient) : CancelKnockRoom {
override suspend fun invoke(roomId: RoomId): Result<Unit> {
return client
.getPendingRoom(roomId)
?.leave()
?: Result.failure(IllegalStateException("No pending room found"))
}
}

View file

@ -31,6 +31,7 @@ object JoinRoomModule {
client: MatrixClient, client: MatrixClient,
joinRoom: JoinRoom, joinRoom: JoinRoom,
knockRoom: KnockRoom, knockRoom: KnockRoom,
cancelKnockRoom: CancelKnockRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta, buildMeta: BuildMeta,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
@ -51,6 +52,7 @@ object JoinRoomModule {
matrixClient = client, matrixClient = client,
joinRoom = joinRoom, joinRoom = joinRoom,
knockRoom = knockRoom, knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta, buildMeta = buildMeta,
) )

View file

@ -10,14 +10,26 @@ package io.element.android.features.joinroom.impl.di
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import javax.inject.Inject import javax.inject.Inject
interface KnockRoom { interface KnockRoom {
suspend operator fun invoke(roomId: RoomId): Result<Unit> suspend operator fun invoke(
roomIdOrAlias: RoomIdOrAlias,
message: String,
serverNames: List<String>,
): Result<Unit>
} }
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultKnockRoom @Inject constructor(private val client: MatrixClient) : KnockRoom { class DefaultKnockRoom @Inject constructor(private val client: MatrixClient) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = client.knockRoom(roomId) override suspend fun invoke(
roomIdOrAlias: RoomIdOrAlias,
message: String,
serverNames: List<String>
): Result<Unit> {
return client
.knockRoom(roomIdOrAlias, message, serverNames)
.map { }
}
} }

View file

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Cancel request"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Yes, cancel"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Are you sure that you want to cancel your request to join this room?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Cancel request to join"</string>
<string name="screen_join_room_join_action">"Join room"</string> <string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</string> <string name="screen_join_room_knock_action">"Send request to join"</string>
<string name="screen_join_room_knock_message_description">"Message (optional)"</string>
<string name="screen_join_room_knock_sent_description">"You will receive an invite to join the room if your request is accepted."</string>
<string name="screen_join_room_knock_sent_title">"Request to join sent"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string> <string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string> <string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string> <string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.joinroom.impl
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.simulateLongTask
class FakeCancelKnockRoom(
var lambda: (RoomId) -> Result<Unit> = { Result.success(Unit) }
) : CancelKnockRoom {
override suspend fun invoke(roomId: RoomId) = simulateLongTask {
lambda(roomId)
}
}

View file

@ -8,10 +8,13 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.tests.testutils.simulateLongTask
class FakeKnockRoom( class FakeKnockRoom(
var lambda: (RoomId) -> Result<Unit> = { Result.success(Unit) } var lambda: (RoomIdOrAlias, String, List<String>) -> Result<Unit> = { _, _, _ -> Result.success(Unit) }
) : KnockRoom { ) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = lambda(roomId) override suspend fun invoke(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<Unit> = simulateLongTask {
lambda(roomIdOrAlias, message, serverNames)
}
} }

View file

@ -12,6 +12,7 @@ import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
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
@ -59,6 +61,8 @@ class JoinRoomPresenterTest {
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias())) assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.knockAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.applicationName).isEqualTo("AppName") assertThat(state.applicationName).isEqualTo("AppName")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -214,7 +218,7 @@ class JoinRoomPresenterTest {
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
state.eventSink(JoinRoomEvents.ClearError) state.eventSink(JoinRoomEvents.ClearActionStates)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized)
@ -325,16 +329,20 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - emit knock room event`() = runTest { fun `present - emit knock room event`() = runTest {
val knockRoomSuccess = lambdaRecorder { _: RoomId -> val knockMessage = "Knock message"
val knockRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: String, _: List<String> ->
Result.success(Unit) Result.success(Unit)
} }
val knockRoomFailure = lambdaRecorder { roomId: RoomId -> val knockRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: String, _: List<String> ->
Result.failure<Unit>(RuntimeException("Failed to knock room $roomId")) Result.failure<Unit>(RuntimeException("Failed to knock room $roomIdOrAlias"))
} }
val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess) val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess)
val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom) val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom)
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state ->
state.eventSink(JoinRoomEvents.UpdateKnockMessage(knockMessage))
}
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.KnockRoom) state.eventSink(JoinRoomEvents.KnockRoom)
} }
@ -353,8 +361,46 @@ class JoinRoomPresenterTest {
} }
assert(knockRoomSuccess) assert(knockRoomSuccess)
.isCalledOnce() .isCalledOnce()
.with(value(A_ROOM_ID)) .with(value(A_ROOM_ID.toRoomIdOrAlias()), value(knockMessage), any())
assert(knockRoomFailure) assert(knockRoomFailure)
.isCalledOnce()
.with(value(A_ROOM_ID.toRoomIdOrAlias()), value(knockMessage), any())
}
@Test
fun `present - emit cancel knock room event`() = runTest {
val cancelKnockRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(Unit)
}
val cancelKnockRoomFailure = lambdaRecorder { roomId: RoomId ->
Result.failure<Unit>(RuntimeException("Failed to knock room $roomId"))
}
val cancelKnockRoom = FakeCancelKnockRoom(cancelKnockRoomSuccess)
val presenter = createJoinRoomPresenter(cancelKnockRoom = cancelKnockRoom)
presenter.test {
skipItems(1)
awaitItem().also { state ->
state.eventSink(JoinRoomEvents.CancelKnock(true))
}
awaitItem().also { state ->
assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.ConfirmingNoParams)
state.eventSink(JoinRoomEvents.CancelKnock(false))
}
assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state ->
assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Success(Unit))
cancelKnockRoom.lambda = cancelKnockRoomFailure
state.eventSink(JoinRoomEvents.CancelKnock(false))
}
assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state ->
assertThat(state.cancelKnockAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
assert(cancelKnockRoomFailure)
.isCalledOnce()
.with(value(A_ROOM_ID))
assert(cancelKnockRoomSuccess)
.isCalledOnce() .isCalledOnce()
.with(value(A_ROOM_ID)) .with(value(A_ROOM_ID))
} }
@ -474,6 +520,7 @@ class JoinRoomPresenterTest {
Result.success(Unit) Result.success(Unit)
}, },
knockRoom: KnockRoom = FakeKnockRoom(), knockRoom: KnockRoom = FakeKnockRoom(),
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() } acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter { ): JoinRoomPresenter {
@ -486,6 +533,7 @@ class JoinRoomPresenterTest {
matrixClient = matrixClient, matrixClient = matrixClient,
joinRoom = FakeJoinRoom(joinRoomLambda), joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom, knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
buildMeta = buildMeta, buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
) )

View file

@ -61,6 +61,7 @@ class JoinRoomViewTest {
rule.setJoinRoomView( rule.setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockMessage = "Knock knock",
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
@ -79,7 +80,34 @@ class JoinRoomViewTest {
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearError) eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
}
@Test
fun `clicking on cancel knock request emit the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_cancel_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true))
}
@Test
fun `clicking on closing Cancel Knock error emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
cancelKnockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
} }
@Test @Test
@ -93,7 +121,7 @@ class JoinRoomViewTest {
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearError) eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
} }
@Test @Test
@ -170,6 +198,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinR
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onJoinSuccess: () -> Unit = EnsureNeverCalled(), onJoinSuccess: () -> Unit = EnsureNeverCalled(),
onKnockSuccess: () -> Unit = EnsureNeverCalled(), onKnockSuccess: () -> Unit = EnsureNeverCalled(),
onCancelKnockSuccess: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
JoinRoomView( JoinRoomView(
@ -177,6 +206,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinR
onBackClick = onBackClick, onBackClick = onBackClick,
onJoinSuccess = onJoinSuccess, onJoinSuccess = onJoinSuccess,
onKnockSuccess = onKnockSuccess, onKnockSuccess = onKnockSuccess,
onCancelKnockSuccess = onCancelKnockSuccess
) )
} }
} }

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="leave_conversation_alert_subtitle">"Вы уверены, что хотите покинуть беседу?"</string> <string name="leave_conversation_alert_subtitle">"Вы уверены, что хотите покинуть беседу? Эта беседа не является общедоступной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string> <string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string>
<string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения."</string> <string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является общедоступной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string> <string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string>
</resources> </resources>

View file

@ -22,6 +22,8 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -397,8 +399,7 @@ class SendLocationPresenterTest {
) )
fakeMessageComposerContext.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
transactionId = null,
content = "" content = ""
) )
} }
@ -446,8 +447,7 @@ class SendLocationPresenterTest {
) )
fakeMessageComposerContext.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
transactionId = null,
content = "" content = ""
) )
} }

View file

@ -20,6 +20,7 @@ import io.element.android.features.lockscreen.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
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.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -51,7 +52,7 @@ fun SetupBiometricView(
private fun SetupBiometricHeader() { private fun SetupBiometricHeader() {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
iconImageVector = Icons.Default.Fingerprint, iconStyle = BigIcon.Style.Default(Icons.Default.Fingerprint),
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth), subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth),
) )

View file

@ -33,6 +33,7 @@ import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.components.PinEntryTextField import io.element.android.features.lockscreen.impl.components.PinEntryTextField
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -88,7 +89,7 @@ private fun SetupPinHeader(
stringResource(id = R.string.screen_app_lock_setup_choose_pin) stringResource(id = R.string.screen_app_lock_setup_choose_pin)
}, },
subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName), subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName),
iconImageVector = Icons.Filled.Lock, iconStyle = BigIcon.Style.Default(Icons.Filled.Lock),
) )
} }
} }

View file

@ -51,7 +51,7 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@ -96,7 +96,7 @@ fun PinUnlockView(
latestOnSuccessLogout(state.signOutAction.data) latestOnSuccessLogout(state.signOutAction.data)
} }
} }
AsyncAction.Confirming, is AsyncAction.Confirming,
is AsyncAction.Failure, is AsyncAction.Failure,
AsyncAction.Uninitialized -> Unit AsyncAction.Uninitialized -> Unit
} }
@ -299,7 +299,7 @@ private fun PinUnlockHeader(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (isInAppUnlock) { if (isInAppUnlock) {
RoundedIconAtom(imageVector = Icons.Filled.Lock) BigIcon(style = BigIcon.Style.Default(Icons.Filled.Lock))
} else { } else {
Icon( Icon(
modifier = Modifier modifier = Modifier

View file

@ -4,7 +4,7 @@
<string name="screen_app_lock_biometric_unlock">"биометрическая разблокировка"</string> <string name="screen_app_lock_biometric_unlock">"биометрическая разблокировка"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string> <string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string>
<string name="screen_app_lock_forgot_pin">"Забыли PIN-код?"</string> <string name="screen_app_lock_forgot_pin">"Забыли PIN-код?"</string>
<string name="screen_app_lock_settings_change_pin">"Измените PIN-код"</string> <string name="screen_app_lock_settings_change_pin">"Изменить PIN-код"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешить биометрическую разблокировку"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешить биометрическую разблокировку"</string>
<string name="screen_app_lock_settings_remove_pin">"Удалить PIN-код"</string> <string name="screen_app_lock_settings_remove_pin">"Удалить PIN-код"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Вы действительно хотите удалить PIN-код?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Вы действительно хотите удалить PIN-код?"</string>
@ -22,16 +22,16 @@
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string>
<string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код"</string> <string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код"</string>
<string name="screen_app_lock_signout_alert_title">"Вы выходите из системы"</string> <string name="screen_app_lock_signout_alert_title">"Выполняется выход из системы"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Вы попытались разблокировать %1$d раз"</item> <item quantity="one">"У вас осталась %1$d попытка на разблокировку"</item>
<item quantity="few">"Вы попытались разблокировать %1$d раз"</item> <item quantity="few">"У вас остались %1$d попытки на разблокировку"</item>
<item quantity="many">"Вы попытались разблокировать много раз"</item> <item quantity="many">"У вас осталось %1$d попыток на разблокировку"</item>
</plurals> </plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin"> <plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Неверный PIN-код. У вас остался %1$d шанс"</item> <item quantity="one">"Неверный PIN-код. У вас осталась %1$d попытка"</item>
<item quantity="few">"Неверный PIN-код. У вас остался %1$d шансов"</item> <item quantity="few">"Неверный PIN-код. У вас остались %1$d попытки"</item>
<item quantity="many">"Неверный PIN-код. У вас остался %1$d шанса"</item> <item quantity="many">"Неверный PIN-код. У вас осталось %1$d попыток"</item>
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string> <string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string>
<string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string> <string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string>

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -34,6 +33,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderVie
import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
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
@ -74,8 +74,7 @@ fun ChangeAccountProviderView(
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Home, iconStyle = BigIcon.Style.Default(Icons.Filled.Home),
iconTint = MaterialTheme.colorScheme.primary,
title = stringResource(id = R.string.screen_change_account_provider_title), title = stringResource(id = R.string.screen_change_account_provider_title),
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
) )

View file

@ -27,6 +27,7 @@ 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.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -92,7 +93,8 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!! val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) { if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now // Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow()) val oidcPrompt = if (params.isAccountCreation) OidcPrompt.Create else OidcPrompt.Consent
LoginFlow.OidcFlow(authenticationService.getOidcUrl(oidcPrompt).getOrThrow())
} else if (params.isAccountCreation) { } else if (params.isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl) val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginFlow.AccountCreationFlow(url) LoginFlow.AccountCreationFlow(url)

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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
@ -59,7 +60,7 @@ fun ConfirmAccountProviderView(
header = { header = {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp), modifier = Modifier.padding(top = 60.dp),
iconImageVector = Icons.Filled.AccountCircle, iconStyle = BigIcon.Style.Default(Icons.Filled.AccountCircle),
title = stringResource( title = stringResource(
id = if (state.isAccountCreation) { id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_title R.string.screen_account_provider_signup_title

View file

@ -48,6 +48,7 @@ import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
@ -110,7 +111,7 @@ fun LoginPasswordView(
// Title // Title
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp), modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.AccountCircle, iconStyle = BigIcon.Style.Default(Icons.Filled.AccountCircle),
title = stringResource( title = stringResource(
id = R.string.screen_account_provider_signin_title, id = R.string.screen_account_provider_signin_title,
state.accountProvider.title state.accountProvider.title

View file

@ -9,7 +9,7 @@ package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class QrCodeConfirmationStepPreviewProvider : PreviewParameterProvider<QrCodeConfirmationStep> { class QrCodeConfirmationStepProvider : PreviewParameterProvider<QrCodeConfirmationStep> {
override val values: Sequence<QrCodeConfirmationStep> override val values: Sequence<QrCodeConfirmationStep>
get() = sequenceOf( get() = sequenceOf(
QrCodeConfirmationStep.DisplayCheckCode("12"), QrCodeConfirmationStep.DisplayCheckCode("12"),

View file

@ -148,7 +148,7 @@ private fun Buttons(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepPreviewProvider::class) step: QrCodeConfirmationStep) { internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepProvider::class) step: QrCodeConfirmationStep) {
ElementPreview { ElementPreview {
QrCodeConfirmationView( QrCodeConfirmationView(
step = step, step = step,

View file

@ -189,7 +189,7 @@ private fun ColumnScope.Buttons(
} }
} }
AsyncAction.Uninitialized, AsyncAction.Uninitialized,
AsyncAction.Confirming -> Unit is AsyncAction.Confirming -> Unit
} }
} }
} }

View file

@ -48,6 +48,7 @@ import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -94,7 +95,7 @@ fun SearchAccountProviderView(
item { item {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = CompoundIcons.Search(), iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
title = stringResource(id = R.string.screen_account_provider_form_title), title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
) )

View file

@ -60,6 +60,7 @@
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Адсканіруйце QR-код з дапамогай гэтай прылады"</string> <string name="screen_qr_code_login_initial_state_item_4">"Адсканіруйце QR-код з дапамогай гэтай прылады"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Даступна толькі ў тым выпадку, калі ваш правайдар уліковага запісу гэта падтрымлівае."</string>
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"</string> <string name="screen_qr_code_login_initial_state_title">"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string>

View file

@ -60,6 +60,7 @@ Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízen
<string name="screen_qr_code_login_initial_state_item_3">"Vybrat %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Vybrat %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Připojit nové zařízení\""</string> <string name="screen_qr_code_login_initial_state_item_3_action">"\"Připojit nové zařízení\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Naskenujte QR kód pomocí tohoto zařízení"</string> <string name="screen_qr_code_login_initial_state_item_4">"Naskenujte QR kód pomocí tohoto zařízení"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Dostupné pouze v případě, že to váš poskytovatel účtu podporuje."</string>
<string name="screen_qr_code_login_initial_state_title">"Otevřete %1$s na jiném zařízení pro získání QR kódu"</string> <string name="screen_qr_code_login_initial_state_title">"Otevřete %1$s na jiném zařízení pro získání QR kódu"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použijte QR kód zobrazený na druhém zařízení."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Použijte QR kód zobrazený na druhém zařízení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string>

View file

@ -60,6 +60,7 @@
<string name="screen_qr_code_login_initial_state_item_3">"Επιλογή %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Επιλογή %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"«Σύνδεση νέας συσκευής»"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"«Σύνδεση νέας συσκευής»"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Σάρωσε τον κωδικό QR με αυτήν τη συσκευή"</string> <string name="screen_qr_code_login_initial_state_item_4">"Σάρωσε τον κωδικό QR με αυτήν τη συσκευή"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Διατίθεται μόνο εάν ο πάροχος του λογαριασμού σου το υποστηρίζει."</string>
<string name="screen_qr_code_login_initial_state_title">"Άνοιγμα %1$s σε άλλη συσκευή για να λήψη κωδικού QR"</string> <string name="screen_qr_code_login_initial_state_title">"Άνοιγμα %1$s σε άλλη συσκευή για να λήψη κωδικού QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Προσπάθησε ξανά"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Προσπάθησε ξανά"</string>

View file

@ -60,6 +60,7 @@ Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega."</strin
<string name="screen_qr_code_login_initial_state_item_3">"Vali %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Vali %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Seo uus seade“"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"„Seo uus seade“"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Skaneeri QR-koodi selle seadmega"</string> <string name="screen_qr_code_login_initial_state_item_4">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"See funktsionaalsus on sadaval vaid siis, kui sinu teenusepakkuja seda toetab."</string>
<string name="screen_qr_code_login_initial_state_title">"QR-koodi saamiseks ava %1$s oma teises seadmes"</string> <string name="screen_qr_code_login_initial_state_title">"QR-koodi saamiseks ava %1$s oma teises seadmes"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Kasuta teises seadmes näidatavat QR-koodi"</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Kasuta teises seadmes näidatavat QR-koodi"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Proovi uuesti"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Proovi uuesti"</string>

View file

@ -58,6 +58,7 @@
<string name="screen_qr_code_login_initial_state_item_3">"Choisissez %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Choisissez %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Associer une nouvelle session”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Associer une nouvelle session”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scanner le code QR avec cet appareil"</string> <string name="screen_qr_code_login_initial_state_item_4">"Scanner le code QR avec cet appareil"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponible uniquement si votre fournisseur de compte le supporte."</string>
<string name="screen_qr_code_login_initial_state_title">"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"</string> <string name="screen_qr_code_login_initial_state_title">"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Scannez le QR code affiché sur lautre appareil."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Scannez le QR code affiché sur lautre appareil."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Essayer à nouveau"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Essayer à nouveau"</string>

View file

@ -60,6 +60,7 @@ Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik e
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Új eszköz összekapcsolása”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"„Új eszköz összekapcsolása”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Olvassa be a QR-kódot ezzel az eszközzel"</string> <string name="screen_qr_code_login_initial_state_item_4">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Csak akkor érhető el, ha a fiókszolgáltató támogatja."</string>
<string name="screen_qr_code_login_initial_state_title">"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."</string> <string name="screen_qr_code_login_initial_state_title">"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Használja a másik eszközön látható QR-kódot."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Használja a másik eszközön látható QR-kódot."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string>

View file

@ -39,13 +39,20 @@
<string name="screen_qr_code_login_connection_note_secure_state_title">"Verbinding niet veilig"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Verbinding niet veilig"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Daar word je gevraagd om de twee cijfers in te voeren die op dit apparaat worden weergegeven."</string> <string name="screen_qr_code_login_device_code_subtitle">"Daar word je gevraagd om de twee cijfers in te voeren die op dit apparaat worden weergegeven."</string>
<string name="screen_qr_code_login_device_code_title">"Voer het onderstaande nummer in op je andere apparaat"</string> <string name="screen_qr_code_login_device_code_title">"Voer het onderstaande nummer in op je andere apparaat"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Log in op een ander apparaat en probeer opnieuw, of gebruik een ander apparaat dat al is ingelogd."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Ander apparaat is niet ingelogd"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"De aanmelding is geannuleerd op het andere apparaat."</string> <string name="screen_qr_code_login_error_cancelled_subtitle">"De aanmelding is geannuleerd op het andere apparaat."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Login verzoek geannuleerd"</string> <string name="screen_qr_code_login_error_cancelled_title">"Login verzoek geannuleerd"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"De aanmelding is geweigerd op het andere apparaat."</string> <string name="screen_qr_code_login_error_declined_subtitle">"De aanmelding is geweigerd op het andere apparaat."</string>
<string name="screen_qr_code_login_error_declined_title">"Aanmelden geweigerd"</string> <string name="screen_qr_code_login_error_declined_title">"Aanmelden geweigerd"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Aanmelden is verlopen. Probeer het opnieuw."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Aanmelden is verlopen. Probeer het opnieuw."</string>
<string name="screen_qr_code_login_error_expired_title">"De aanmelding was niet op tijd voltooid"</string> <string name="screen_qr_code_login_error_expired_title">"De aanmelding was niet op tijd voltooid"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Jouw andere apparaat ondersteunt geen inloggen op %s met een QR code.
Probeer handmatig in te loggen, of scan de QR code met een ander apparaat."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR-code wordt niet ondersteund"</string> <string name="screen_qr_code_login_error_linking_not_suported_title">"QR-code wordt niet ondersteund"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Je accountprovider ondersteunt geen %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s wordt niet ondersteund"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Klaar om te scannen"</string> <string name="screen_qr_code_login_initial_state_button_title">"Klaar om te scannen"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s op een desktopapparaat"</string> <string name="screen_qr_code_login_initial_state_item_1">"Open %1$s op een desktopapparaat"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik op je afbeelding"</string> <string name="screen_qr_code_login_initial_state_item_2">"Klik op je afbeelding"</string>

View file

@ -60,6 +60,7 @@ Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro disposi
<string name="screen_qr_code_login_initial_state_item_3">"Seleciona %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Seleciona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Ligar novo dispositivo”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Ligar novo dispositivo”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Lê o código QR com este dispositivo"</string> <string name="screen_qr_code_login_initial_state_item_4">"Lê o código QR com este dispositivo"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponível apenas se o seu fornecedor de conta o suportar."</string>
<string name="screen_qr_code_login_initial_state_title">"Abre a %1$s noutro dispositivo para obteres o código QR"</string> <string name="screen_qr_code_login_initial_state_title">"Abre a %1$s noutro dispositivo para obteres o código QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Lê o código QR apresentado no outro dispositivo."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Lê o código QR apresentado no outro dispositivo."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Tentar novamente"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Tentar novamente"</string>

View file

@ -5,9 +5,9 @@
<string name="screen_account_provider_form_notice">"Введите поисковый запрос или адрес домена."</string> <string name="screen_account_provider_form_notice">"Введите поисковый запрос или адрес домена."</string>
<string name="screen_account_provider_form_subtitle">"Поиск компании, сообщества или частного сервера."</string> <string name="screen_account_provider_form_subtitle">"Поиск компании, сообщества или частного сервера."</string>
<string name="screen_account_provider_form_title">"Поиск сервера учетной записи"</string> <string name="screen_account_provider_form_title">"Поиск сервера учетной записи"</string>
<string name="screen_account_provider_signin_subtitle">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string> <string name="screen_account_provider_signin_subtitle">"Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем."</string>
<string name="screen_account_provider_signin_title">"Вы собираетесь войти в %s"</string> <string name="screen_account_provider_signin_title">"Вы собираетесь войти в %s"</string>
<string name="screen_account_provider_signup_subtitle">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string> <string name="screen_account_provider_signup_subtitle">"Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем."</string>
<string name="screen_account_provider_signup_title">"Вы собираетесь создать учетную запись на %s"</string> <string name="screen_account_provider_signup_title">"Вы собираетесь создать учетную запись на %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation."</string> <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation."</string>
<string name="screen_change_account_provider_other">"Другое"</string> <string name="screen_change_account_provider_other">"Другое"</string>
@ -27,14 +27,14 @@
<string name="screen_login_error_invalid_user_id">"Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля."</string> <string name="screen_login_error_refresh_tokens">"Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля."</string>
<string name="screen_login_error_unsupported_authentication">"Выбранный домашний сервер не поддерживает пароль или логин OIDC. Пожалуйста, свяжитесь с администратором или выберите другой домашний сервер."</string> <string name="screen_login_error_unsupported_authentication">"Выбранный домашний сервер не поддерживает пароль или логин OIDC. Пожалуйста, свяжитесь с администратором или выберите другой домашний сервер."</string>
<string name="screen_login_form_header">"Введите сведения о себе"</string> <string name="screen_login_form_header">"Введите свои данные"</string>
<string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string> <string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>
<string name="screen_login_title">"Рады видеть вас снова!"</string> <string name="screen_login_title">"Рады видеть вас снова!"</string>
<string name="screen_login_title_with_homeserver">"Войти в %1$s"</string> <string name="screen_login_title_with_homeserver">"Войти в %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Установление безопасного соединения"</string> <string name="screen_qr_code_login_connecting_subtitle">"Установление безопасного соединения"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Что теперь?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Что теперь?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Попробуйте снова войти в систему с помощью QR-кода, если это была сетевая проблема"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string>
@ -55,11 +55,12 @@
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Поставщик учетной записи не поддерживает %1$s."</string> <string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Поставщик учетной записи не поддерживает %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s не поддерживается"</string> <string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s не поддерживается"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Готово к сканированию"</string> <string name="screen_qr_code_login_initial_state_button_title">"Готово к сканированию"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string> <string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на компьютере"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string> <string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Выберите %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string> <string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Отсканируйте QR-код с помощью этого устройства"</string> <string name="screen_qr_code_login_initial_state_item_4">"Отсканируйте QR-код с помощью этого устройства"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Доступно только в том случае, если ваш поставщик учетной записи поддерживает это."</string>
<string name="screen_qr_code_login_initial_state_title">"Откройте %1$s на другом устройстве, чтобы получить QR-код"</string> <string name="screen_qr_code_login_initial_state_title">"Откройте %1$s на другом устройстве, чтобы получить QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Используйте QR-код, показанный на другом устройстве."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Используйте QR-код, показанный на другом устройстве."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string>
@ -76,7 +77,7 @@
<string name="screen_server_confirmation_change_server">"Сменить поставщика учетной записи"</string> <string name="screen_server_confirmation_change_server">"Сменить поставщика учетной записи"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Частный сервер для сотрудников Element."</string> <string name="screen_server_confirmation_message_login_element_dot_io">"Частный сервер для сотрудников Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>
<string name="screen_server_confirmation_message_register">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string> <string name="screen_server_confirmation_message_register">"Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем."</string>
<string name="screen_server_confirmation_title_login">"Вы собираетесь войти в %1$s"</string> <string name="screen_server_confirmation_title_login">"Вы собираетесь войти в %1$s"</string>
<string name="screen_server_confirmation_title_register">"Вы собираетесь создать учетную запись на %1$s"</string> <string name="screen_server_confirmation_title_register">"Вы собираетесь создать учетную запись на %1$s"</string>
</resources> </resources>

View file

@ -60,6 +60,7 @@ Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariade
<string name="screen_qr_code_login_initial_state_item_3">"Vyberte %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Vyberte %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Prepojiť nové zariadenie“"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"„Prepojiť nové zariadenie“"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Naskenujte QR kód pomocou tohto zariadenia"</string> <string name="screen_qr_code_login_initial_state_item_4">"Naskenujte QR kód pomocou tohto zariadenia"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Dostupné iba v prípade, že to podporuje váš poskytovateľ účtu."</string>
<string name="screen_qr_code_login_initial_state_title">"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"</string> <string name="screen_qr_code_login_initial_state_title">"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použite QR kód zobrazený na druhom zariadení."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Použite QR kód zobrazený na druhom zariadení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string>

View file

@ -14,7 +14,7 @@ open class DirectLogoutStateProvider : PreviewParameterProvider<DirectLogoutStat
override val values: Sequence<DirectLogoutState> override val values: Sequence<DirectLogoutState>
get() = sequenceOf( get() = sequenceOf(
aDirectLogoutState(), aDirectLogoutState(),
aDirectLogoutState(logoutAction = AsyncAction.Confirming), aDirectLogoutState(logoutAction = AsyncAction.ConfirmingNoParams),
aDirectLogoutState(logoutAction = AsyncAction.Loading), aDirectLogoutState(logoutAction = AsyncAction.Loading),
aDirectLogoutState(logoutAction = AsyncAction.Failure(Exception("Error"))), aDirectLogoutState(logoutAction = AsyncAction.Failure(Exception("Error"))),
aDirectLogoutState(logoutAction = AsyncAction.Success("success")), aDirectLogoutState(logoutAction = AsyncAction.Success("success")),

View file

@ -64,7 +64,7 @@ class LogoutPresenter @Inject constructor(
if (logoutAction.value.isConfirming() || event.ignoreSdkError) { if (logoutAction.value.isConfirming() || event.ignoreSdkError) {
localCoroutineScope.logout(logoutAction, event.ignoreSdkError) localCoroutineScope.logout(logoutAction, event.ignoreSdkError)
} else { } else {
logoutAction.value = AsyncAction.Confirming logoutAction.value = AsyncAction.ConfirmingNoParams
} }
} }
LogoutEvents.CloseDialogs -> { LogoutEvents.CloseDialogs -> {

View file

@ -21,7 +21,7 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
aLogoutState(isLastDevice = true), aLogoutState(isLastDevice = true),
aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)), aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)),
aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done), aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done),
aLogoutState(logoutAction = AsyncAction.Confirming), aLogoutState(logoutAction = AsyncAction.ConfirmingNoParams),
aLogoutState(logoutAction = AsyncAction.Loading), aLogoutState(logoutAction = AsyncAction.Loading),
aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))), aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))),
aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))), aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))),

View file

@ -52,7 +52,7 @@ class DirectLogoutPresenter @Inject constructor(
if (logoutAction.value.isConfirming() || event.ignoreSdkError) { if (logoutAction.value.isConfirming() || event.ignoreSdkError) {
localCoroutineScope.logout(logoutAction, event.ignoreSdkError) localCoroutineScope.logout(logoutAction, event.ignoreSdkError)
} else { } else {
logoutAction.value = AsyncAction.Confirming logoutAction.value = AsyncAction.ConfirmingNoParams
} }
} }
DirectLogoutEvents.CloseDialogs -> { DirectLogoutEvents.CloseDialogs -> {

View file

@ -29,7 +29,7 @@ fun LogoutActionDialog(
when (state) { when (state) {
AsyncAction.Uninitialized -> AsyncAction.Uninitialized ->
Unit Unit
AsyncAction.Confirming -> is AsyncAction.Confirming ->
LogoutConfirmationDialog( LogoutConfirmationDialog(
onSubmitClick = onConfirmClick, onSubmitClick = onConfirmClick,
onDismiss = onDismissDialog onDismiss = onDismissDialog

View file

@ -107,7 +107,7 @@ class LogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink.invoke(LogoutEvents.CloseDialogs) initialState.eventSink.invoke(LogoutEvents.CloseDialogs)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -123,7 +123,7 @@ class LogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -148,7 +148,7 @@ class LogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -180,7 +180,7 @@ class LogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)

View file

@ -48,7 +48,7 @@ class LogoutViewTest {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setLogoutView( rule.setLogoutView(
aLogoutState( aLogoutState(
logoutAction = AsyncAction.Confirming, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )

View file

@ -36,7 +36,7 @@ class DefaultDirectLogoutViewTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( rule.setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
@ -49,7 +49,7 @@ class DefaultDirectLogoutViewTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( rule.setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
@ -63,7 +63,7 @@ class DefaultDirectLogoutViewTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( rule.setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )

View file

@ -88,7 +88,7 @@ class DirectLogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) initialState.eventSink.invoke(DirectLogoutEvents.CloseDialogs)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -104,7 +104,7 @@ class DirectLogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -129,7 +129,7 @@ class DirectLogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -161,7 +161,7 @@ class DirectLogoutPresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)

View file

@ -9,11 +9,12 @@ package io.element.android.features.logout.test
import io.element.android.features.logout.api.LogoutUseCase import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeLogoutUseCase( class FakeLogoutUseCase(
var logoutLambda: (Boolean) -> String? = { lambdaError() } var logoutLambda: (Boolean) -> String? = { lambdaError() }
) : LogoutUseCase { ) : LogoutUseCase {
override suspend fun logout(ignoreSdkError: Boolean): String? { override suspend fun logout(ignoreSdkError: Boolean): String? = simulateLongTask {
return logoutLambda(ignoreSdkError) logoutLambda(ignoreSdkError)
} }
} }

View file

@ -9,11 +9,11 @@ package io.element.android.features.messages.impl
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.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
sealed interface MessagesEvents { sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val uniqueId: UniqueId) : MessagesEvents data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data object Dismiss : MessagesEvents data object Dismiss : MessagesEvents
} }

View file

@ -50,7 +50,6 @@ import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox 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.inputs
import io.element.android.libraries.architecture.overlay.Overlay import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.show import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -324,7 +323,8 @@ class MessagesFlowNode @AssistedInject constructor(
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
name = event.content.filename ?: event.content.body, filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType, mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize, formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension fileExtension = event.content.fileExtension
@ -341,7 +341,8 @@ class MessagesFlowNode @AssistedInject constructor(
if (event.content.preferredMediaSource != null) { if (event.content.preferredMediaSource != null) {
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
name = event.content.body, filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType, mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize, formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension fileExtension = event.content.fileExtension
@ -358,7 +359,8 @@ class MessagesFlowNode @AssistedInject constructor(
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
name = event.content.filename ?: event.content.body, filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType, mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize, formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension fileExtension = event.content.fileExtension
@ -372,7 +374,8 @@ class MessagesFlowNode @AssistedInject constructor(
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
name = event.content.body, filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType, mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize, formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension fileExtension = event.content.fileExtension
@ -386,7 +389,8 @@ class MessagesFlowNode @AssistedInject constructor(
is TimelineItemAudioContent -> { is TimelineItemAudioContent -> {
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
name = event.content.body, filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType, mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize, formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension fileExtension = event.content.fileExtension

View file

@ -62,7 +62,6 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
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
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
@ -73,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther 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.powerlevels.canSendMessage import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData 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
@ -191,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor(
) )
} }
is MessagesEvents.ToggleReaction -> { is MessagesEvents.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.uniqueId) localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
} }
is MessagesEvents.InviteDialogDismissed -> { is MessagesEvents.InviteDialogDismissed -> {
hasDismissedInviteDialog = true hasDismissedInviteDialog = true
@ -327,10 +327,10 @@ class MessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.toggleReaction( private fun CoroutineScope.toggleReaction(
emoji: String, emoji: String,
uniqueId: UniqueId, eventOrTransactionId: EventOrTransactionId,
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
toggleReaction(emoji, uniqueId) toggleReaction(emoji, eventOrTransactionId)
.onFailure { Timber.e(it) } .onFailure { Timber.e(it) }
} }
} }
@ -360,7 +360,7 @@ class MessagesPresenter @AssistedInject constructor(
private suspend fun handleActionRedact(event: TimelineItem.Event) { private suspend fun handleActionRedact(event: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null) redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null)
.onFailure { Timber.e(it) } .onFailure { Timber.e(it) }
} }
} }
@ -377,8 +377,7 @@ class MessagesPresenter @AssistedInject constructor(
} }
else -> { else -> {
val composerMode = MessageComposerMode.Edit( val composerMode = MessageComposerMode.Edit(
targetEvent.eventId, targetEvent.eventOrTransactionId,
targetEvent.transactionId,
(targetEvent.content as? TimelineItemTextBasedContent)?.let { (targetEvent.content as? TimelineItemTextBasedContent)?.let {
if (enableTextFormatting) { if (enableTextFormatting) {
it.htmlBody ?: it.body it.htmlBody ?: it.body

View file

@ -168,7 +168,7 @@ fun MessagesView(
} }
fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) { fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.id)) state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventOrTransactionId))
} }
fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) { fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) {

View file

@ -31,103 +31,109 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
return sequenceOf( return sequenceOf(
anActionListState(), anActionListState(),
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())), anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent().copy( event = aTimelineItemEvent(
reactionsState = reactionsState timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent( event = aTimelineItemEvent(
content = aTimelineItemImageContent(), content = aTimelineItemImageContent(),
displayNameAmbiguous = true, displayNameAmbiguous = true,
).copy( timelineItemReactions = reactionsState,
reactionsState = reactionsState,
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemVideoContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemFileContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemAudioContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemAudioContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVoiceContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemVoiceContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = false, displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
), ),
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemPollContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemPollContent(),
timelineItemReactions = reactionsState
), ),
displayEmojiReactions = false, displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(), actions = aTimelineItemPollActionList(),
), ),
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent().copy( event = aTimelineItemEvent(
reactionsState = reactionsState, timelineItemReactions = reactionsState,
messageShield = MessageShield.UnknownDevice(isCritical = true) messageShield = MessageShield.UnknownDevice(isCritical = true)
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
@ -135,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
) )
), ),
anActionListState().copy( anActionListState(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(), event = aTimelineItemEvent(),
displayEmojiReactions = true, displayEmojiReactions = true,

View file

@ -146,7 +146,7 @@ fun ActionListView(
onDismissRequest = ::onDismiss, onDismissRequest = ::onDismiss,
modifier = modifier, modifier = modifier,
) { ) {
SheetContent( ActionListViewContent(
state = state, state = state,
onActionClick = ::onItemActionClick, onActionClick = ::onItemActionClick,
onEmojiReactionClick = ::onEmojiReactionClick, onEmojiReactionClick = ::onEmojiReactionClick,
@ -161,7 +161,7 @@ fun ActionListView(
} }
@Composable @Composable
private fun SheetContent( private fun ActionListViewContent(
state: ActionListState, state: ActionListState,
onActionClick: (TimelineItemAction) -> Unit, onActionClick: (TimelineItemAction) -> Unit,
onEmojiReactionClick: (String) -> Unit, onEmojiReactionClick: (String) -> Unit,
@ -269,19 +269,19 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) } content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) }
} }
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemStickerContent -> { is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemAudioContent -> { is TimelineItemAudioContent -> {
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemVoiceContent -> { is TimelineItemVoiceContent -> {
content = { ContentForBody(textContent) } content = { ContentForBody(textContent) }
@ -442,10 +442,10 @@ private fun EmojiButton(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun SheetContentPreview( internal fun ActionListViewContentPreview(
@PreviewParameter(ActionListStateProvider::class) state: ActionListState @PreviewParameter(ActionListStateProvider::class) state: ActionListState
) = ElementPreview { ) = ElementPreview {
SheetContent( ActionListViewContent(
state = state, state = state,
onActionClick = {}, onActionClick = {},
onEmojiReactionClick = {}, onEmojiReactionClick = {},

View file

@ -32,7 +32,5 @@ sealed class TimelineItemAction(
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true) data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end) data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin) data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin)
// TODO use the Unpin compound icon when available.
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_pin)
} }

View file

@ -105,13 +105,13 @@ class IdentityChangeStatePresenter @Inject constructor(
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember( private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId, userId = userId,
disambiguatedDisplayName = disambiguatedDisplayName, displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert), avatarData = getAvatarData(AvatarSize.ComposerAlert),
) )
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember( private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId, userId = userId,
disambiguatedDisplayName = userId.value, displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData( avatarData = AvatarData(
id = userId.value, id = userId.value,
name = null, name = null,

View file

@ -20,8 +20,16 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
anIdentityChangeState(), anIdentityChangeState(),
anIdentityChangeState( anIdentityChangeState(
roomMemberIdentityStateChanges = listOf( roomMemberIdentityStateChanges = listOf(
RoomMemberIdentityStateChange( aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(disambiguatedDisplayName = "Alice"), identityRoomMember = anIdentityRoomMember(),
identityState = IdentityState.PinViolation,
),
),
),
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityState = IdentityState.PinViolation, identityState = IdentityState.PinViolation,
), ),
), ),
@ -29,6 +37,14 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
) )
} }
internal fun aRoomMemberIdentityStateChange(
identityRoomMember: IdentityRoomMember = anIdentityRoomMember(),
identityState: IdentityState = IdentityState.PinViolation,
) = RoomMemberIdentityStateChange(
identityRoomMember = identityRoomMember,
identityState = identityState,
)
internal fun anIdentityChangeState( internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(), roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
) = IdentityChangeState( ) = IdentityChangeState(
@ -38,7 +54,7 @@ internal fun anIdentityChangeState(
internal fun anIdentityRoomMember( internal fun anIdentityRoomMember(
userId: UserId = UserId("@alice:example.com"), userId: UserId = UserId("@alice:example.com"),
disambiguatedDisplayName: String = userId.value, displayNameOrDefault: String = userId.extractedDisplayName,
avatarData: AvatarData = AvatarData( avatarData: AvatarData = AvatarData(
id = userId.value, id = userId.value,
name = null, name = null,
@ -47,6 +63,6 @@ internal fun anIdentityRoomMember(
), ),
) = IdentityRoomMember( ) = IdentityRoomMember(
userId = userId, userId = userId,
disambiguatedDisplayName = disambiguatedDisplayName, displayNameOrDefault = displayNameOrDefault,
avatarData = avatarData, avatarData = avatarData,
) )

View file

@ -40,13 +40,27 @@ fun IdentityChangeStateView(
avatar = pinViolationIdentityChange.identityRoomMember.avatarData, avatar = pinViolationIdentityChange.identityRoomMember.avatarData,
content = buildAnnotatedString { content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more) val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val displayName = pinViolationIdentityChange.identityRoomMember.displayNameOrDefault
val userIdStr = stringResource(
CommonStrings.crypto_identity_change_pin_violation_new_user_id,
pinViolationIdentityChange.identityRoomMember.userId,
)
val fullText = stringResource( val fullText = stringResource(
id = CommonStrings.crypto_identity_change_pin_violation, id = CommonStrings.crypto_identity_change_pin_violation_new,
pinViolationIdentityChange.identityRoomMember.disambiguatedDisplayName, displayName,
userIdStr,
learnMoreStr, learnMoreStr,
) )
val learnMoreStartIndex = fullText.indexOf(learnMoreStr)
append(fullText) append(fullText)
val userIdStartIndex = fullText.indexOf(userIdStr)
addStyle(
style = SpanStyle(
fontWeight = FontWeight.Bold,
),
start = userIdStartIndex,
end = userIdStartIndex + userIdStr.length,
)
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
addStyle( addStyle(
style = SpanStyle( style = SpanStyle(
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,

View file

@ -12,6 +12,6 @@ import io.element.android.libraries.matrix.api.core.UserId
data class IdentityRoomMember( data class IdentityRoomMember(
val userId: UserId, val userId: UserId,
val disambiguatedDisplayName: String, val displayNameOrDefault: String,
val avatarData: AvatarData, val avatarData: AvatarData,
) )

View file

@ -24,11 +24,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -80,9 +79,7 @@ fun ResolveVerifiedUserSendFailureView(
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
title = state.verifiedUserSendFailure.title(), title = state.verifiedUserSendFailure.title(),
subTitle = state.verifiedUserSendFailure.subtitle(), subTitle = state.verifiedUserSendFailure.subtitle(),
iconImageVector = CompoundIcons.Error(), iconStyle = BigIcon.Style.AlertSolid,
iconTint = ElementTheme.colors.iconCriticalPrimary,
iconBackgroundTint = ElementTheme.colors.bgCriticalSubtle,
) )
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
@ -442,12 +443,11 @@ class MessageComposerPresenter @Inject constructor(
intentionalMentions = message.intentionalMentions intentionalMentions = message.intentionalMentions
) )
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
// First try to edit the message in the current timeline // First try to edit the message in the current timeline
editMessage(eventId, transactionId, message.markdown, message.html, message.intentionalMentions) editMessage(capturedMode.eventOrTransactionId, message.markdown, message.html, message.intentionalMentions)
.onFailure { cause -> .onFailure { cause ->
val eventId = capturedMode.eventOrTransactionId.eventId
if (cause is TimelineException.EventNotFound && eventId != null) { if (cause is TimelineException.EventNotFound && eventId != null) {
// if the event is not found in the timeline, try to edit the message directly // if the event is not found in the timeline, try to edit the message directly
room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions) room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions)
@ -581,8 +581,7 @@ class MessageComposerPresenter @Inject constructor(
when (val draftType = draft.draftType) { when (val draftType = draft.draftType) {
ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal
is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit( is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit(
eventId = draftType.eventId, eventOrTransactionId = draftType.eventId.toEventOrTransactionId(),
transactionId = null,
content = htmlText ?: markdownText content = htmlText ?: markdownText
) )
is ComposerDraftType.Reply -> { is ComposerDraftType.Reply -> {
@ -611,7 +610,7 @@ class MessageComposerPresenter @Inject constructor(
val draftType = when (val mode = messageComposerContext.composerMode) { val draftType = when (val mode = messageComposerContext.composerMode) {
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
mode.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
} }
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId) is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
} }

View file

@ -19,7 +19,11 @@ internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<Pinn
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1), aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1),
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 5), aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 5),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0), aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 2, currentPinnedMessageIndex = 0), aLoadedPinnedMessagesBannerState(
knownPinnedMessagesCount = 2,
currentPinnedMessageIndex = 0,
message = "This is a pinned long message to check the wrapping behavior",
),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0), aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 0), aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 1), aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 1),
@ -40,9 +44,10 @@ internal fun aLoadingPinnedMessagesBannerState(
internal fun aLoadedPinnedMessagesBannerState( internal fun aLoadedPinnedMessagesBannerState(
currentPinnedMessageIndex: Int = 0, currentPinnedMessageIndex: Int = 0,
knownPinnedMessagesCount: Int = 1, knownPinnedMessagesCount: Int = 1,
message: String = "This is a pinned message",
currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem( currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
eventId = EventId("\$" + Random.nextInt().toString()), eventId = EventId("\$" + Random.nextInt().toString()),
formatted = AnnotatedString("This is a pinned message") formatted = AnnotatedString(message)
), ),
eventSink: (PinnedMessagesBannerEvents) -> Unit = {} eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState.Loaded( ) = PinnedMessagesBannerState.Loaded(

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
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.runtime.Composable import androidx.compose.runtime.Composable
@ -98,9 +99,8 @@ private fun PinnedMessagesBannerRow(
} }
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) { ) {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(26.dp))
PinIndicators( PinIndicators(
pinIndex = state.currentPinnedMessageIndex(), pinIndex = state.currentPinnedMessageIndex(),
pinsCount = state.pinnedMessagesCount(), pinsCount = state.pinnedMessagesCount(),
@ -109,7 +109,9 @@ private fun PinnedMessagesBannerRow(
imageVector = CompoundIcons.PinSolid(), imageVector = CompoundIcons.PinSolid(),
contentDescription = null, contentDescription = null,
tint = ElementTheme.materialColors.secondary, tint = ElementTheme.materialColors.secondary,
modifier = Modifier.size(20.dp) modifier = Modifier
.padding(horizontal = 10.dp)
.size(20.dp)
) )
PinnedMessageItem( PinnedMessageItem(
index = state.currentPinnedMessageIndex(), index = state.currentPinnedMessageIndex(),

View file

@ -24,6 +24,7 @@ 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 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.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
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -36,9 +37,9 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.icons.CompoundDrawables
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.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@ -154,7 +155,7 @@ private fun PinnedMessagesListEmpty(
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline), title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline),
subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText), subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText),
iconResourceId = CompoundDrawables.ic_compound_pin, iconStyle = BigIcon.Style.Default(CompoundIcons.Pin()),
) )
} }
} }

View file

@ -164,10 +164,10 @@ internal fun aTimelineItemEvent(
groupPosition = groupPosition, groupPosition = groupPosition,
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfoProvider = { debugInfo },
isThreaded = isThreaded, isThreaded = isThreaded,
origin = null, origin = null,
messageShield = messageShield, timelineItemDebugInfoProvider = { debugInfo },
messageShieldProvider = { messageShield },
) )
} }

View file

@ -26,7 +26,9 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
// For consistency, ensure that there is a message in the timeline (the last one) with an error. // For consistency, ensure that there is a message in the timeline (the last one) with an error.
val messageShield = aCriticalShield() val messageShield = aCriticalShield()
val items = listOf( val items = listOf(
(timelineItems.first() as TimelineItem.Event).copy(messageShield = messageShield) (timelineItems.first() as TimelineItem.Event).copy(
messageShieldProvider = { messageShield },
)
) + timelineItems.drop(1) ) + timelineItems.drop(1)
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),

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