Merge branch 'release/25.06.3'

This commit is contained in:
Jorge Martín
2025-06-19 12:31:52 +02:00
422 changed files with 4045 additions and 1842 deletions

View File

@@ -1,3 +1,20 @@
Changes in Element X v25.06.2
=============================
## What's Changed
### 🐛 Bugfixes
* Fix crash when using Element Call on API <= 30 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4847
* Element Call: add delay before selecting the default audio device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4854
* Fix for message composer losing focus in Compose 1.8.0 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4853
### Dependency upgrades
* chore(deps): update plugin dependencycheck to v12.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4840
* deps (matrix rust sdk) : bump version to 25.06.10 by @ganfra in https://github.com/element-hq/element-x-android/pull/4855
### Others
* feat: Support matrix: links by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4839
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.1...v25.06.2
## What's Changed
### ✨ Features
* Enable support for Android Auto. by @bmarty in https://github.com/element-hq/element-x-android/pull/4818

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Keluar &amp; Tingkatkan"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Homeserver Anda tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."</string>
</resources>

View File

@@ -16,7 +16,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -29,7 +28,6 @@ class MatrixSessionCacheTest {
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()

View File

@@ -0,0 +1,2 @@
Main changes in this version: add support for tombstoned rooms, improve notification reliability.
Full changelog: https://github.com/element-hq/element-x-android/releases

View File

@@ -44,6 +44,7 @@ import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
class CallScreenPresenter @AssistedInject constructor(
@Assisted private val callType: CallType,
@@ -165,6 +167,13 @@ class CallScreenPresenter @AssistedInject constructor(
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor)
isJoinedCall = false
coroutineScope.launch {
// Wait for a couple of seconds to receive the hangup message
// If we don't get it in time, we close the screen anyway
delay(2.seconds)
close(callWidgetDriver.value, navigator)
}
} else {
coroutineScope.launch {
close(callWidgetDriver.value, navigator)

View File

@@ -38,6 +38,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
import io.element.android.features.call.impl.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
@@ -105,6 +106,14 @@ internal fun CallScreenView(
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
invalidAudioDeviceReason?.let {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
invalidAudioDeviceReason = null
}
}
CallWebView(
modifier = Modifier
.padding(padding)
@@ -130,7 +139,11 @@ internal fun CallScreenView(
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
@@ -157,6 +170,21 @@ internal fun CallScreenView(
}
}
@Composable
private fun InvalidAudioDeviceDialog(
invalidAudioDeviceReason: InvalidAudioDeviceReason,
onDismiss: () -> Unit,
) {
ErrorDialog(
content = when (invalidAudioDeviceReason) {
InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> {
stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled)
}
},
onSubmit = onDismiss,
)
}
@Composable
private fun CallWebView(
url: AsyncData<String>,
@@ -277,3 +305,9 @@ internal fun CallScreenPipViewPreview(
requestPermissions = { _, _ -> },
)
}
@PreviewsDayNight
@Composable
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}

View File

@@ -40,8 +40,22 @@ import kotlin.time.Duration.Companion.milliseconds
class WebViewAudioManager(
private val webView: WebView,
private val coroutineScope: CoroutineScope,
private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit,
) {
// The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
/**
* Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12,
* since the WebView approach breaks when using the legacy Bluetooth audio APIs.
*/
private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S
/**
* This flag indicates whether the WebView audio is enabled or not. By default, it is enabled.
*/
private val isWebViewAudioEnabled = AtomicBoolean(true)
/**
* The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
*/
private val wantedDeviceTypes = listOf(
// Paired bluetooth device with microphone
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
@@ -60,6 +74,10 @@ class WebViewAudioManager(
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/**
* This wake lock is used to turn off the screen when the proximity sensor is triggered during a call,
* if the selected audio device is the built-in earpiece.
*/
private val proximitySensorWakeLock by lazy {
webView.context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
@@ -296,12 +314,13 @@ class WebViewAudioManager(
* @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
*/
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
val selectedDevice = availableDevices
.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
}
}
}
expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice)
@@ -361,6 +380,13 @@ class WebViewAudioManager(
// On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type
if (device != null) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
Timber.w("Bluetooth audio devices are disabled on this Android version")
setAudioEnabled(false)
onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED)
return
}
setAudioEnabled(true)
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
@@ -380,6 +406,19 @@ class WebViewAudioManager(
proximitySensorWakeLock?.release()
}
}
/**
* Sets whether the audio is enabled for Element Call in the WebView.
* It will only perform the change if the audio state has changed.
*/
private fun setAudioEnabled(enabled: Boolean) {
coroutineScope.launch(Dispatchers.Main) {
Timber.d("Setting audio enabled in Element Call: $enabled")
if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) {
webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null)
}
}
}
}
/**
@@ -434,6 +473,10 @@ private fun isBuiltIn(type: Int): Boolean = when (type) {
else -> false
}
enum class InvalidAudioDeviceReason {
BT_AUDIO_DEVICE_DISABLED,
}
/**
* This class is used to serialize the audio device information to JSON.
*/

View File

@@ -11,6 +11,10 @@ import android.webkit.WebView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Documentation about the `controls` command can be found here:
* https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#picture-in-picture
*/
class WebViewPipController(
private val webView: WebView,
) : PipController {

View File

@@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Cliquez pour retourner à lappel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ne prend pas en charge lutilisation daccessoires Bluetooth dans cette version dAndroid. Sélectionnez une autre sortie audio."</string>
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
</resources>

View File

@@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Pågående samtale"</string>
<string name="call_foreground_service_message_android">"Trykk for å gå tilbake til samtalen"</string>
<string name="call_foreground_service_title_android">"☎️ Samtale pågår"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call støtter ikke bruk av Bluetooth-lydenheter i denne Android-versjonen. Velg en annen lydenhet."</string>
<string name="screen_incoming_call_subtitle_android">"Innkommende Element-anrop"</string>
</resources>

View File

@@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

View File

@@ -21,6 +21,10 @@ Puedes cambiar esto en cualquier momento en los ajustes de la sala."</string>
<string name="screen_create_room_topic_label">"Tema (opcional)"</string>
<string name="screen_room_directory_search_title">"Directorio de salas"</string>
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Unirse a una sala por su dirección"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Dirección no válida"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Introducir…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Sala encontrada"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"No se encontró la sala"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"p. ej., #nombre-de-la-sala:matrix.org"</string>
</resources>

View File

@@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalComposeUiApi::class)
package io.element.android.features.logout.impl
import androidx.compose.foundation.clickable
@@ -32,7 +30,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Harap konfirmasi bahwa Anda ingin menonaktifkan akun Anda. Tindakan ini tidak dapat diurungkan."</string>
<string name="screen_deactivate_account_delete_all_messages">"Hapus semua pesan saya"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Peringatan: Pengguna masa depan mungkin melihat percakapan yang tidak lengkap."</string>
<string name="screen_deactivate_account_description">"Penonaktifan akun Anda %1$s, ini akan:"</string>
<string name="screen_deactivate_account_description_bold_part">"tidak dapat diurungkan"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s akun Anda (Anda tidak dapat masuk kembali, dan ID Anda tidak dapat digunakan kembali)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Nonaktifkan secara permanen"</string>
<string name="screen_deactivate_account_list_item_2">"Mengeluarkan Anda dari semua ruangan obrolan."</string>
<string name="screen_deactivate_account_list_item_3">"Hapus informasi akun Anda dari server identitas kami."</string>
<string name="screen_deactivate_account_list_item_4">"Pesan Anda akan tetap terlihat oleh pengguna terdaftar tetapi tidak akan tersedia bagi pengguna baru atau tidak terdaftar jika Anda memilih untuk menghapusnya."</string>
<string name="screen_deactivate_account_title">"Nonaktifkan akun"</string>
</resources>

View File

@@ -12,7 +12,7 @@
<string name="screen_identity_waiting_on_other_device">"Esperando en otro dispositivo…"</string>
<string name="screen_notification_optin_subtitle">"Puedes cambiar la configuración más tarde."</string>
<string name="screen_notification_optin_title">"Activa las notificaciones y nunca te pierdas un mensaje"</string>
<string name="screen_session_verification_enter_recovery_key">"Introduzca la clave de recuperación"</string>
<string name="screen_session_verification_enter_recovery_key">"Introduce la clave de recuperación"</string>
<string name="screen_welcome_bullet_1">"Las llamadas, las encuestas, la búsqueda y más se agregarán más adelante este año."</string>
<string name="screen_welcome_bullet_2">"El historial de mensajes de las salas cifradas aún no está disponible."</string>
<string name="screen_welcome_bullet_3">"Nos encantaría saber de ti, haznos saber lo que piensas a través de la página de configuración."</string>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_decline_and_block_block_user_option_description">"No verás ningún mensaje ni invitación a sala que provenga de este usuario"</string>
<string name="screen_decline_and_block_block_user_option_title">"Bloquear usuario"</string>
<string name="screen_decline_and_block_report_user_option_description">"Denunciar esta sala a tu proveedor de cuentas."</string>
<string name="screen_decline_and_block_report_user_reason_placeholder">"Describe el motivo de la denuncia…"</string>
<string name="screen_decline_and_block_title">"Rechazar y bloquear"</string>
<string name="screen_invites_decline_chat_message">"¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rechazar la invitación"</string>
<string name="screen_invites_decline_direct_chat_message">"¿Estás seguro de que quieres rechazar este chat privado con %1$s?"</string>

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_decline_and_block_block_user_option_description">"Вы не увидите сообщений или приглашений в комнату от этого пользователя"</string>
<string name="screen_decline_and_block_block_user_option_title">"Заблокировать пользователя"</string>
<string name="screen_decline_and_block_report_user_option_description">"Сообщите об этой комнате своему поставщику учетной записи."</string>
<string name="screen_decline_and_block_report_user_reason_placeholder">"Опишите причину жалобы…"</string>
<string name="screen_decline_and_block_title">"Отклонить и заблокировать"</string>
<string name="screen_invites_decline_chat_message">"Вы уверены, что хотите отклонить приглашение в %1$s?"</string>

View File

@@ -79,7 +79,7 @@ class JoinRoomFlowNode @AssistedInject constructor(
JoinRoomView(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
onJoinSuccess = {},
onForgetSuccess = ::navigateUp,
onCancelKnockSuccess = {},
onKnockSuccess = {},

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Batalkan permintaan"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Ya, batalkan"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Apakah Anda yakin ingin membatalkan permintaan Anda untuk bergabung dengan ruangan ini?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Batalkan permintaan untuk bergabung"</string>
<string name="screen_join_room_join_action">"Gabung dengan ruangan"</string>
<string name="screen_join_room_knock_action">"Ketuk untuk bergabung"</string>
<string name="screen_join_room_knock_message_description">"Pesan (opsional)"</string>
<string name="screen_join_room_knock_sent_description">"Anda akan menerima undangan untuk bergabung dengan ruangan jika permintaan Anda diterima."</string>
<string name="screen_join_room_knock_sent_title">"Permintaan untuk bergabung dikirim"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s belum mendukung space. Anda dapat mengakses space di web."</string>
<string name="screen_join_room_space_not_supported_title">"Space belum didukung"</string>
<string name="screen_join_room_subtitle_knock">"Klik tombol di bawah ini dan administrator kamar akan diberi tahu. Anda akan dapat bergabung dengan percakapan setelah disetujui."</string>
<string name="screen_join_room_subtitle_no_preview">"Anda harus menjadi anggota ruangan ini untuk melihat riwayat pesan."</string>
<string name="screen_join_room_title_knock">"Ingin bergabung dengan ruangan ini?"</string>
<string name="screen_join_room_title_no_preview">"Pratinjau tidak tersedia"</string>
</resources>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Ya, terima semua"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Apakah Anda yakin ingin menerima semua permintaan untuk bergabung?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Terima semua permintaan"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Terima semua"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Kami tidak dapat menerima semua permintaan. Apakah Anda ingin mencoba lagi?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Gagal menerima semua permintaan"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Menerima semua permintaan untuk bergabung"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Kami tidak dapat menerima permintaan ini. Apakah Anda ingin mencoba lagi?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Gagal menerima permintaan"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Menerima permintaan untuk bergabung"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ya, tolak dan cekal"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Apakah Anda yakin ingin menolak dan mencekal %1$s? Pengguna ini tidak akan dapat meminta akses untuk bergabung ke ruangan ini lagi."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Tolak dan cekal akses"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Menolak dan mencekal akses"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ya, tolak"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Apakah Anda yakin ingin menolak permintaan %1$s untuk bergabung ke ruangan ini?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Tolak akses"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Tolak dan cekal"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Kami tidak dapat menolak permintaan ini. Apakah Anda ingin mencoba lagi?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Gagal menolak permintaan"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Menolak permintaan untuk bergabung"</string>
<string name="screen_knock_requests_list_empty_state_description">"Ketika seseorang akan meminta untuk bergabung dengan ruangan, Anda akan dapat melihat permintaan mereka di sini."</string>
<string name="screen_knock_requests_list_empty_state_title">"Tidak ada permintaan yang tertunda untuk bergabung"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Memuat permintaan untuk bergabung…"</string>
<string name="screen_knock_requests_list_title">"Permintaan untuk bergabung"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="other">"%1$s +%2$d lainnya ingin bergabung ke ruangan ini"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Lihat semua"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Terima"</string>
<string name="screen_room_single_knock_request_title">"%1$s ingin bergabung ke ruangan ini"</string>
<string name="screen_room_single_knock_request_view_button_title">"Lihat"</string>
</resources>

View File

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

View File

@@ -26,6 +26,10 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.message.replyInThread
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -98,6 +102,18 @@ class SendLocationPresenter @Inject constructor(
event: SendLocationEvents.SendLocation,
mode: SendLocationState.Mode,
) {
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val replyParams = replyMode?.replyToDetails?.let { details ->
if (replyMode.inThread) {
replyInThread(details.eventId())
} else {
ReplyParameters(
inReplyToEventId = details.eventId(),
enforceThreadReply = false,
replyWithinThread = false
)
}
}
when (mode) {
SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri()
@@ -106,7 +122,8 @@ class SendLocationPresenter @Inject constructor(
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.PIN
assetType = AssetType.PIN,
replyParameters = replyParams,
)
analyticsService.capture(
Composer(
@@ -124,7 +141,8 @@ class SendLocationPresenter @Inject constructor(
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.SENDER
assetType = AssetType.SENDER,
replyParameters = replyParams,
)
analyticsService.capture(
Composer(

View File

@@ -22,6 +22,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
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
@@ -263,7 +264,7 @@ class SendLocationPresenterTest {
@Test
fun `share sender location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, Result<Unit>> { _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
@@ -310,6 +311,7 @@ class SendLocationPresenterTest {
value(null),
value(15),
value(AssetType.SENDER),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
@@ -326,7 +328,7 @@ class SendLocationPresenterTest {
@Test
fun `share pin location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, Result<Unit>> { _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
@@ -373,6 +375,7 @@ class SendLocationPresenterTest {
value(null),
value(15),
value(AssetType.PIN),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
@@ -389,7 +392,7 @@ class SendLocationPresenterTest {
@Test
fun `composer context passes through analytics`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, Result<Unit>> { _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -124,6 +125,7 @@ private fun SetupPinContent(
}
@Composable
@ReadOnlyComposable
private fun SetupPinFailure.content(): String {
return when (this) {
SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_content)
@@ -132,6 +134,7 @@ private fun SetupPinFailure.content(): String {
}
@Composable
@ReadOnlyComposable
private fun SetupPinFailure.title(): String {
return when (this) {
SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_title)

View File

@@ -9,6 +9,7 @@ package io.element.android.features.login.impl.error
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
@@ -21,6 +22,7 @@ sealed class ChangeServerError : Throwable() {
val messageStr: String? = null,
) : ChangeServerError() {
@Composable
@ReadOnlyComposable
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
}

View File

@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.focus.FocusDirection
@@ -170,7 +169,6 @@ fun LoginPasswordView(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LoginForm(
state: LoginPasswordState,

View File

@@ -1,22 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Cambiar el proveedor de la cuenta"</string>
<string name="screen_account_provider_form_hint">"Dirección del servidor principal"</string>
<string name="screen_account_provider_form_notice">"Introduzca un término de búsqueda o una dirección de dominio."</string>
<string name="screen_account_provider_change">"Cambiar proveedor de cuentas"</string>
<string name="screen_account_provider_form_hint">"Dirección del servidor base"</string>
<string name="screen_account_provider_form_notice">"Introduce un término de búsqueda o una dirección de dominio."</string>
<string name="screen_account_provider_form_subtitle">"Busca una empresa, comunidad o servidor privado."</string>
<string name="screen_account_provider_form_title">"Encontrar un proveedor de cuenta"</string>
<string name="screen_account_provider_form_title">"Encontrar un proveedor de cuentas"</string>
<string name="screen_account_provider_signin_subtitle">"Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos."</string>
<string name="screen_account_provider_signin_title">"Estás a punto de iniciar sesión en %s"</string>
<string name="screen_account_provider_signup_subtitle">"Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos."</string>
<string name="screen_account_provider_signup_title">"Estás a punto de crear una cuenta en %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org es un servidor grande y gratuito en la red pública Matrix para una comunicación segura y descentralizada, administrado por la Fundación Matrix.org."</string>
<string name="screen_change_account_provider_other">"Otro"</string>
<string name="screen_change_account_provider_subtitle">"Usa un proveedor de cuenta diferente, como tu propio servidor privado o una cuenta de trabajo."</string>
<string name="screen_change_account_provider_title">"Cambiar el proveedor de la cuenta"</string>
<string name="screen_change_server_error_invalid_homeserver">"No hemos podido acceder a este servidor. Comprueba que has introducido correctamente la dirección del servidor. Si la dirección es correcta, ponte en contacto con el administrador del servidor para obtener más ayuda."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync no está disponible debido a un problema en el archivo well-known:
<string name="screen_change_account_provider_subtitle">"Usa un proveedor de cuentas diferente, como tu propio servidor privado o una cuenta de trabajo."</string>
<string name="screen_change_account_provider_title">"Cambiar proveedor de cuentas"</string>
<string name="screen_change_server_error_invalid_homeserver">"No hemos podido acceder a este servidor base. Comprueba que has introducido correctamente la dirección. Si es correcta, ponte en contacto con el administrador de tu servidor base para obtener más ayuda."</string>
<string name="screen_change_server_error_invalid_well_known">"El servidor no está disponible debido a un problema en el archivo .well-known:
%1$s"</string>
<string name="screen_change_server_form_header">"Dirección del homeserver"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"El proveedor de cuentas seleccionado no admite sliding sync. Es necesario actualizar el servidor para usar %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s no está autorizado para conectarse a %2$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_content">"Esta aplicación se ha configurado para permitir: %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_title">"No se permite el proveedor de cuentas %1$s."</string>
<string name="screen_change_server_form_header">"URL del servidor base"</string>
<string name="screen_change_server_form_notice">"Introduce una dirección de dominio."</string>
<string name="screen_change_server_subtitle">"¿Cuál es la dirección de tu servidor?"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
<string name="screen_create_account_title">"Crear cuenta"</string>
@@ -24,12 +29,13 @@
<string name="screen_login_error_invalid_credentials">"Usuario y/o contraseña incorrectos"</string>
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña."</string>
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
<string name="screen_login_error_unsupported_authentication">"El servidor base seleccionado no admite el inicio de sesión usando contraseña ni OIDC. Ponte en contacto con tu administrador o elige otro servidor base."</string>
<string name="screen_login_form_header">"Introduce tus datos"</string>
<string name="screen_login_subtitle">"Matrix es una red abierta para una comunicación segura y descentralizada."</string>
<string name="screen_login_title">"¡Hola de nuevo!"</string>
<string name="screen_login_title_with_homeserver">"Iniciar sesión en %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Iniciar sesión manualmente"</string>
<string name="screen_onboarding_sign_in_to">"Iniciar sesión en %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Iniciar sesión con un código QR"</string>
<string name="screen_onboarding_sign_up">"Crear cuenta"</string>
<string name="screen_onboarding_welcome_message">"Bienvenido al %1$s más rápido de todos los tiempos. Diseñado para la velocidad y la simplicidad."</string>
@@ -56,7 +62,7 @@
Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Código QR no admitido"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Tu proveedor de cuenta no es compatible con %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Tu proveedor de cuentas no es compatible con %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s no admitido"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Listo para escanear"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Abre %1$s en un dispositivo de escritorio"</string>
@@ -64,7 +70,7 @@ Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo
<string name="screen_qr_code_login_initial_state_item_3">"Selecciona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"«Vincular un dispositivo nuevo»"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Escanea el código QR con este dispositivo"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Solo disponible si tu proveedor de cuenta lo admite."</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Sólo disponible si tu proveedor de cuentas lo admite."</string>
<string name="screen_qr_code_login_initial_state_title">"Abre %1$s en otro dispositivo para obtener el código QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Usa el código QR que se muestra en el otro dispositivo."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Intentar de nuevo"</string>
@@ -76,12 +82,13 @@ Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo
<string name="screen_qr_code_login_start_over_button">"Empezar de nuevo"</string>
<string name="screen_qr_code_login_unknown_error_description">"Se ha producido un error inesperado. Vuelve a intentarlo."</string>
<string name="screen_qr_code_login_verify_code_loading">"A la espera de tu otro dispositivo"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Puede que el proveedor de tu cuenta te pida el siguiente código para verificar el inicio de sesión."</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Puede que tu proveedor de cuentas te pida el siguiente código para verificar el inicio de sesión."</string>
<string name="screen_qr_code_login_verify_code_title">"Tu código de verificación"</string>
<string name="screen_server_confirmation_change_server">"Cambiar el proveedor de la cuenta"</string>
<string name="screen_server_confirmation_change_server">"Cambiar proveedor de cuentas"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un servidor privado para los empleados de Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix es una red abierta para una comunicación segura y descentralizada."</string>
<string name="screen_server_confirmation_message_register">"Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos."</string>
<string name="screen_server_confirmation_title_login">"Estás a punto de iniciar sesión en %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Elegir proveedor de cuentas"</string>
<string name="screen_server_confirmation_title_register">"Estás a punto de crear una cuenta en %1$s"</string>
</resources>

View File

@@ -89,5 +89,6 @@ Prøv å logge på manuelt, eller skann QR-koden med en annen enhet."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon."</string>
<string name="screen_server_confirmation_message_register">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string>
<string name="screen_server_confirmation_title_login">"Du er i ferd med å logge inn på %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Velg kontoleverandør"</string>
<string name="screen_server_confirmation_title_register">"Du er i ferd med å opprette en konto på %1$s"</string>
</resources>

View File

@@ -14,8 +14,12 @@
<string name="screen_change_account_provider_subtitle">"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись."</string>
<string name="screen_change_account_provider_title">"Сменить поставщика учетной записи"</string>
<string name="screen_change_server_error_invalid_homeserver">"Нам не удалось связаться с этим домашним сервером. Убедитесь, что вы правильно ввели URL-адрес домашнего сервера. Если URL-адрес указан правильно, обратитесь к администратору домашнего сервера за дополнительной помощью."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync недоступен из-за проблемы в известном файле:
<string name="screen_change_server_error_invalid_well_known">"Сервер недоступен из-за проблемы в файле .well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Выбранный провайдер аккаунтов не поддерживает Sliding sync. Для использования %1$s необходимо обновление сервера."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s отказано в подключении к %2$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_content">"Это приложение настроено таким образом, чтобы разрешить: %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_title">"Поставщик учетной записи %1$s не разрешен."</string>
<string name="screen_change_server_form_header">"URL-адрес домашнего сервера"</string>
<string name="screen_change_server_form_notice">"Введите адрес домена."</string>
<string name="screen_change_server_subtitle">"Какой адрес у вашего сервера?"</string>
@@ -31,6 +35,7 @@
<string name="screen_login_title">"Рады видеть вас снова!"</string>
<string name="screen_login_title_with_homeserver">"Войти в %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Войти вручную"</string>
<string name="screen_onboarding_sign_in_to">"Войти в %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Войти QR-кодом"</string>
<string name="screen_onboarding_sign_up">"Создать учетную запись"</string>
<string name="screen_onboarding_welcome_message">"Добро пожаловать в самый быстрый клиент %1$s. Ориентирован на скорость и простоту."</string>
@@ -84,5 +89,6 @@
<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_title_login">"Вы собираетесь войти в %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Выберите поставщика учетной записи"</string>
<string name="screen_server_confirmation_title_register">"Вы собираетесь создать учетную запись на %1$s"</string>
</resources>

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList
@@ -19,4 +20,5 @@ interface MessagesNavigator {
fun onReportContentClick(eventId: EventId, senderId: UserId)
fun onEditPollClick(eventId: EventId)
fun onPreviewAttachment(attachments: ImmutableList<Attachment>)
fun onNavigateToRoom(roomId: RoomId)
}

View File

@@ -49,18 +49,21 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -70,6 +73,7 @@ import kotlinx.coroutines.launch
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: BaseRoom,
@@ -157,7 +161,7 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
handleRoomLinkClick(activity, permalink, eventSink)
handleRoomLinkClick(permalink, eventSink)
}
is PermalinkData.FallbackLink -> {
if (customTab) {
@@ -173,7 +177,6 @@ class MessagesNode @AssistedInject constructor(
}
private fun handleRoomLinkClick(
context: Context,
roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit,
) {
@@ -183,7 +186,7 @@ class MessagesNode @AssistedInject constructor(
eventSink(TimelineEvents.FocusOnEvent(eventId))
} else {
// Click on the same room, ignore
context.toast("Already viewing this room!")
displaySameRoomToast()
}
} else {
callbacks.forEach { it.onPermalinkClick(roomLink) }
@@ -210,6 +213,15 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onPreviewAttachments(attachments) }
}
override fun onNavigateToRoom(roomId: RoomId) {
if (roomId == room.roomId) {
displaySameRoomToast()
} else {
val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias())
callbacks.forEach { it.onPermalinkClick(permalinkData) }
}
}
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
@@ -230,6 +242,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onViewKnockRequests() }
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
@@ -255,13 +271,13 @@ class MessagesNode @AssistedInject constructor(
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
modifier = modifier,
knockRequestsBannerView = {
knockRequestsBannerRenderer.View(
modifier = Modifier,
onViewRequestsClick = this::onViewKnockRequestsClick
)
},
modifier = modifier,
)
roomMemberModerationRenderer.Render(
state = state.roomMemberModerationState,

View File

@@ -152,11 +152,8 @@ class MessagesPresenter @AssistedInject constructor(
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
}
val roomAvatar: AsyncData<AvatarData> by remember {
derivedStateOf { AsyncData.Success(roomInfo.avatarData()) }
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
}
val heroes by remember {
derivedStateOf { roomInfo.heroes().toPersistentList() }
@@ -245,7 +242,7 @@ class MessagesPresenter @AssistedInject constructor(
return MessagesState(
roomId = room.roomId,
roomName = roomName,
roomName = roomInfo.name,
roomAvatar = roomAvatar,
heroes = heroes,
composerState = composerState,
@@ -270,6 +267,7 @@ class MessagesPresenter @AssistedInject constructor(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
successorRoom = roomInfo.successorRoom,
eventSink = { handleEvents(it) }
)
}
@@ -291,7 +289,7 @@ class MessagesPresenter @AssistedInject constructor(
return AvatarData(
id = id.value,
name = name,
url = avatarUrl ?: room.info().avatarUrl,
url = avatarUrl,
size = AvatarSize.TimelineRoom
)
}

View File

@@ -26,13 +26,14 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>,
val roomName: String?,
val roomAvatar: AvatarData,
val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState,
@@ -56,5 +57,8 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState,
val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvents) -> Unit
)
) {
val isTombstoned = successorRoom != null
}

View File

@@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf
@@ -57,10 +58,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true),
aMessagesState(
roomName = AsyncData.Uninitialized,
roomAvatar = AsyncData.Uninitialized,
),
aMessagesState(roomName = null),
aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)),
aMessagesState(
enableVoiceMessages = true,
@@ -85,14 +83,15 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.Verified),
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.Verified),
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
)
}
fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"),
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
roomName: String? = "Room name",
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
@@ -119,6 +118,7 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -147,6 +147,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
successorRoom = successorRoom,
eventSink = eventSink,
)

View File

@@ -82,14 +82,14 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
@@ -101,8 +101,10 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
@@ -190,8 +192,9 @@ fun MessagesView(
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
@@ -211,8 +214,8 @@ fun MessagesView(
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = {
hidingKeyboard {
state.eventSink(MessagesEvents.OnUserClicked(it))
}
state.eventSink(MessagesEvents.OnUserClicked(it))
}
},
onLinkClick = { link, customTab ->
if (customTab) {
@@ -410,6 +413,9 @@ private fun MessagesViewContent(
MessagesViewComposerBottomSheetContents(
subcomposing = subcomposing,
state = state,
onRoomSuccessorClick = { roomId ->
state.timelineState.eventSink(TimelineEvents.NavigateToRoom(roomId = roomId))
},
onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
)
},
@@ -424,52 +430,59 @@ private fun MessagesViewContent(
private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean,
state: MessagesState,
onRoomSuccessorClick: (RoomId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
) {
if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) {
SuggestionsPickerView(
modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available
}
}),
roomId = state.roomId,
roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(),
suggestions = state.composerState.suggestions,
onSelectSuggestion = {
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
when {
state.successorRoom != null -> {
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick)
}
state.userEventPermissions.canSendMessage -> {
Column(modifier = Modifier.fillMaxWidth()) {
SuggestionsPickerView(
modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available
}
}),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
suggestions = state.composerState.suggestions,
onSelectSuggestion = {
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
}
)
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
IdentityChangeStateView(
state = state.identityChangeState,
onLinkClick = onLinkClick,
)
}
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
}
if (verificationViolation != null) {
DisabledComposerView(modifier = Modifier.fillMaxWidth())
} else {
MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)
}
)
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
IdentityChangeStateView(
state = state.identityChangeState,
onLinkClick = onLinkClick,
)
}
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
}
if (verificationViolation != null) {
DisabledComposerView(modifier = Modifier.fillMaxWidth())
} else {
MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)
}
}
} else {
CantSendMessageBanner()
else -> {
CantSendMessageBanner()
}
}
}
@@ -477,7 +490,8 @@ private fun MessagesViewComposerBottomSheetContents(
@Composable
private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
roomAvatar: AvatarData,
isTombstoned: Boolean,
heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?,
@@ -499,19 +513,13 @@ private fun MessagesViewTopBar(
verticalAlignment = Alignment.CenterVertically,
) {
val titleModifier = Modifier.weight(1f, fill = false)
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
heroes = heroes,
modifier = titleModifier
)
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
}
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
isTombstoned = isTombstoned,
heroes = heroes,
modifier = titleModifier
)
when (dmUserIdentityState) {
IdentityState.Verified -> {
@@ -545,23 +553,26 @@ private fun MessagesViewTopBar(
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,
roomName: String?,
roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
CompositeAvatar(
RoomAvatar(
avatarData = roomAvatar,
heroes = heroes,
isTombstoned = isTombstoned,
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = roomName,
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -588,6 +599,22 @@ private fun CantSendMessageBanner() {
}
}
@Composable
private fun SuccessorRoomBanner(
roomSuccessor: SuccessorRoom,
onRoomSuccessorClick: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
ComposerAlertMolecule(
avatar = null,
content = stringResource(R.string.screen_room_timeline_tombstoned_room_message).toAnnotatedString(),
onSubmitClick = { onRoomSuccessorClick(roomSuccessor.roomId) },
modifier = modifier,
isCritical = false,
submitText = stringResource(R.string.screen_room_timeline_tombstoned_room_action)
)
}
@PreviewsDayNight
@Composable
internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview {

View File

@@ -169,7 +169,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable) {
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
// Caption
if (timelineItem.content.caption == null) {

View File

@@ -30,6 +30,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -38,7 +39,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
@@ -52,6 +53,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@@ -394,6 +396,7 @@ private fun VerifiedUserSendFailureView(
modifier: Modifier = Modifier,
) {
@Composable
@ReadOnlyComposable
fun VerifiedUserSendFailure.headline(): String {
return when (this) {
is None -> ""
@@ -436,11 +439,10 @@ private fun EmojiButton(
} else {
Color.Transparent
}
val description = if (isHighlighted) {
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji)
} else {
stringResource(id = CommonStrings.a11y_react_with, emoji)
}
val a11yClickLabel = a11yReactionAction(
emoji = emoji,
userAlreadyReacted = isHighlighted,
)
Box(
modifier = modifier
.size(48.dp)
@@ -452,7 +454,12 @@ private fun EmojiButton(
interactionSource = remember { MutableInteractionSource() }
)
.semantics {
contentDescription = description
onClick(
label = a11yClickLabel,
) {
onClick(emoji)
true
}
},
contentAlignment = Alignment.Center
) {

View File

@@ -8,9 +8,10 @@
package io.element.android.features.messages.impl.draft
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
interface ComposerDraftService {
suspend fun loadDraft(roomId: RoomId, isVolatile: Boolean): ComposerDraft?
suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?, isVolatile: Boolean)
suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft?
suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean)
}

View File

@@ -8,9 +8,10 @@
package io.element.android.features.messages.impl.draft
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
interface ComposerDraftStore {
suspend fun loadDraft(roomId: RoomId): ComposerDraft?
suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?)
suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft?
suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?)
}

View File

@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.draft
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import javax.inject.Inject
@@ -18,12 +19,12 @@ class DefaultComposerDraftService @Inject constructor(
private val volatileComposerDraftStore: VolatileComposerDraftStore,
private val matrixComposerDraftStore: MatrixComposerDraftStore,
) : ComposerDraftService {
override suspend fun loadDraft(roomId: RoomId, isVolatile: Boolean): ComposerDraft? {
return getStore(isVolatile).loadDraft(roomId)
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft? {
return getStore(isVolatile).loadDraft(roomId, threadRoot)
}
override suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?, isVolatile: Boolean) {
getStore(isVolatile).updateDraft(roomId, draft)
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean) {
getStore(isVolatile).updateDraft(roomId, threadRoot, draft)
}
private fun getStore(isVolatile: Boolean): ComposerDraftStore {

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.draft
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.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import timber.log.Timber
import javax.inject.Inject
@@ -20,26 +21,26 @@ import javax.inject.Inject
class MatrixComposerDraftStore @Inject constructor(
private val client: MatrixClient,
) : ComposerDraftStore {
override suspend fun loadDraft(roomId: RoomId): ComposerDraft? {
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? {
return client.getRoom(roomId)?.use { room ->
room.loadComposerDraft()
room.loadComposerDraft(threadRoot)
.onFailure {
Timber.e(it, "Failed to load composer draft for room $roomId")
}
.onSuccess { draft ->
room.clearComposerDraft()
room.clearComposerDraft(threadRoot)
Timber.d("Loaded composer draft for room $roomId : $draft")
}
.getOrNull()
}
}
override suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?) {
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) {
client.getRoom(roomId)?.use { room ->
val updateDraftResult = if (draft == null) {
room.clearComposerDraft()
room.clearComposerDraft(threadRoot)
} else {
room.saveComposerDraft(draft)
room.saveComposerDraft(draft, threadRoot)
}
updateDraftResult
.onFailure {

View File

@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.draft
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import javax.inject.Inject
@@ -17,18 +18,20 @@ import javax.inject.Inject
* Currently it's used to store draft message when moving to edit mode.
*/
class VolatileComposerDraftStore @Inject constructor() : ComposerDraftStore {
private val drafts: MutableMap<RoomId, ComposerDraft> = mutableMapOf()
private val drafts: MutableMap<String, ComposerDraft> = mutableMapOf()
override suspend fun loadDraft(roomId: RoomId): ComposerDraft? {
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? {
val key = threadRoot?.value ?: roomId.value
// Remove the draft from the map when it is loaded
return drafts.remove(roomId)
return drafts.remove(key)
}
override suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?) {
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) {
val key = threadRoot?.value ?: roomId.value
if (draft == null) {
drafts.remove(roomId)
drafts.remove(key)
} else {
drafts[roomId] = draft
drafts[key] = draft
}
}
}

View File

@@ -219,7 +219,12 @@ class MessageComposerPresenter @AssistedInject constructor(
)
LaunchedEffect(Unit) {
val draft = draftService.loadDraft(room.roomId, isVolatile = false)
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
isVolatile = false
)
if (draft != null) {
applyDraft(draft, markdownTextEditorState, richTextEditorState)
}
@@ -539,7 +544,9 @@ class MessageComposerPresenter @AssistedInject constructor(
draftService.updateDraft(
roomId = room.roomId,
draft = draft,
isVolatile = isVolatile
isVolatile = isVolatile,
// TODO support threads in composer
threadRoot = null,
)
}
@@ -700,7 +707,12 @@ class MessageComposerPresenter @AssistedInject constructor(
fromEdit: Boolean,
) {
// Use the volatile draft only when coming from edit mode otherwise.
val draft = draftService.loadDraft(room.roomId, isVolatile = true).takeIf { fromEdit }
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
isVolatile = true
).takeIf { fromEdit }
if (draft != null) {
applyDraft(draft, markdownTextEditorState, richTextEditorState)
} else {

View File

@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@@ -45,7 +46,7 @@ import kotlinx.collections.immutable.persistentListOf
fun SuggestionsPickerView(
roomId: RoomId,
roomName: String?,
roomAvatarData: AvatarData?,
roomAvatarData: AvatarData,
suggestions: ImmutableList<ResolvedSuggestion>,
onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier,
@@ -155,7 +156,7 @@ internal fun SuggestionsPickerViewPreview() {
SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"),
roomName = "Room",
roomAvatarData = null,
roomAvatarData = anAvatarData(),
suggestions = persistentListOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember),

View File

@@ -106,7 +106,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
renderTypingNotifications = false,
typingMembers = persistentListOf(),
reserveSpace = false,
)
),
predecessorRoom = room.predecessorRoom(),
)
}
val timelineProtectionState = timelineProtectionPresenter.present()

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration
@@ -30,6 +31,7 @@ sealed interface TimelineEvents {
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
data class NavigateToRoom(val roomId: RoomId) : EventFromTimelineItem
/**
* Events coming from a poll item.

View File

@@ -178,6 +178,9 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event.event))
}
is TimelineEvents.NavigateToRoom -> {
navigator.onNavigateToRoom(event.roomId)
}
}
}
@@ -257,8 +260,9 @@ class TimelinePresenter @AssistedInject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
roomCallState = roomCallState,
pinnedEventIds = roomInfo.pinnedEventIds.orEmpty(),
pinnedEventIds = roomInfo.pinnedEventIds,
typingNotificationState = typingNotificationState,
predecessorRoom = room.predecessorRoom(),
)
}
}

View File

@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@@ -77,4 +78,5 @@ data class TimelineRoomInfo(
val roomCallState: RoomCallState,
val pinnedEventIds: List<EventId>,
val typingNotificationState: TypingNotificationState,
val predecessorRoom: PredecessorRoom?,
)

View File

@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@@ -246,6 +247,7 @@ internal fun aTimelineRoomInfo(
userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(),
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
predecessorRoom: PredecessorRoom? = null,
) = TimelineRoomInfo(
isDm = isDm,
name = name,
@@ -254,4 +256,5 @@ internal fun aTimelineRoomInfo(
roomCallState = aStandByCallState(),
pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState,
predecessorRoom = predecessorRoom,
)

View File

@@ -76,8 +76,6 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -225,7 +223,6 @@ private fun MessageShieldDialog(state: TimelineState) {
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
private fun TimelinePrefetchingHelper(
lazyListState: LazyListState,

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.a11y
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ReadOnlyComposable
fun a11yReactionAction(
emoji: String,
userAlreadyReacted: Boolean,
): String {
return if (userAlreadyReacted) {
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji)
} else {
stringResource(id = CommonStrings.a11y_react_with, emoji)
}
}
@Composable
@ReadOnlyComposable
fun a11yReactionDetails(
emoji: String,
userAlreadyReacted: Boolean,
reactionCount: Int,
): String {
val reaction = if (emoji.startsWith("mxc://")) {
stringResource(CommonStrings.common_an_image)
} else {
emoji
}
return if (userAlreadyReacted) {
if (reactionCount == 1) {
stringResource(R.string.screen_room_timeline_reaction_you_a11y, reaction)
} else {
pluralStringResource(
R.plurals.screen_room_timeline_reaction_including_you_a11y,
reactionCount - 1,
reactionCount - 1,
reaction,
)
}
} else {
pluralStringResource(
R.plurals.screen_room_timeline_reaction_a11y,
reactionCount,
reactionCount,
reaction,
)
}
}

View File

@@ -21,7 +21,6 @@ internal fun ATimelineItemEventRow(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false,
isHighlighted: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
) = TimelineItemEventRow(
event = event,
@@ -29,7 +28,6 @@ internal fun ATimelineItemEventRow(
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
onEventClick = {},
onLongClick = {},
onLinkClick = {},

View File

@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -59,7 +58,6 @@ private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageEventBubble(
state: BubbleState,
@@ -184,7 +182,7 @@ internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::cl
contentAlignment = Alignment.Center,
) {
Text(
text = "${state.groupPosition.javaClass.simpleName} m:${state.isMine.to01()} h:${state.isHighlighted.to01()}",
text = "${state.groupPosition.javaClass.simpleName} isMine:${state.isMine.to01()}",
style = ElementTheme.typography.fontBodyXsRegular,
)
}

View File

@@ -80,6 +80,7 @@ internal fun MessageShield.toText(): String {
is MessageShield.UnverifiedIdentity -> CommonStrings.event_shield_reason_unverified_identity
is MessageShield.SentInClear -> CommonStrings.event_shield_reason_sent_in_clear
is MessageShield.VerificationViolation -> CommonStrings.event_shield_reason_previously_verified
is MessageShield.MismatchedSender -> CommonStrings.event_shield_mismatched_sender
}
)
}
@@ -91,7 +92,8 @@ internal fun MessageShield.toIcon(): ImageVector {
is MessageShield.UnknownDevice,
is MessageShield.UnsignedDevice,
is MessageShield.UnverifiedIdentity,
is MessageShield.VerificationViolation -> CompoundIcons.HelpSolid()
is MessageShield.VerificationViolation,
is MessageShield.MismatchedSender -> CompoundIcons.HelpSolid()
is MessageShield.SentInClear -> CompoundIcons.LockOff()
}
}
@@ -122,6 +124,9 @@ internal fun MessageShieldViewPreview() {
MessageShieldView(
shield = MessageShield.VerificationViolation(false)
)
MessageShieldView(
shield = MessageShield.MismatchedSender(false)
)
}
}
}

View File

@@ -7,12 +7,8 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
@@ -28,17 +24,14 @@ import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageStateEventContainer(
@Suppress("UNUSED_PARAMETER") isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
// Ignore isHighlighted for now, we need a design decision on it.
val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Surface(
@@ -60,22 +53,9 @@ fun MessageStateEventContainer(
@PreviewsDayNight
@Composable
internal fun MessageStateEventContainerPreview() = ElementPreview {
Column {
MessageStateEventContainer(
isHighlighted = false,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
MessageStateEventContainer(
isHighlighted = true,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
}
MessageStateEventContainer(
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
)
}

View File

@@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
@@ -30,12 +29,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
@@ -50,7 +54,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@Composable
@OptIn(ExperimentalFoundationApi::class)
@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape
fun MessagesReactionButton(
onClick: () -> Unit,
@@ -70,6 +73,27 @@ fun MessagesReactionButton(
buttonColor
}
val a11yText = when (content) {
is MessagesReactionsButtonContent.Icon -> stringResource(id = R.string.screen_room_timeline_add_reaction)
is MessagesReactionsButtonContent.Text -> content.text
is MessagesReactionsButtonContent.Reaction -> {
a11yReactionDetails(
emoji = content.reaction.key,
userAlreadyReacted = content.isHighlighted,
reactionCount = content.reaction.count,
)
}
}
val a11yClickLabel = if (content is MessagesReactionsButtonContent.Reaction) {
a11yReactionAction(
emoji = content.reaction.key,
userAlreadyReacted = content.isHighlighted
)
} else {
""
}
Surface(
modifier = modifier
.background(Color.Transparent)
@@ -88,7 +112,18 @@ fun MessagesReactionButton(
// Inner border, to highlight when selected
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
.padding(vertical = 4.dp, horizontal = 10.dp),
.padding(vertical = 4.dp, horizontal = 10.dp)
.clearAndSetSemantics {
contentDescription = a11yText
if (content is MessagesReactionsButtonContent.Reaction) {
onClick(
label = a11yClickLabel
) {
onClick()
true
}
}
},
color = buttonColor
) {
when (content) {

View File

@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -39,7 +38,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event,

View File

@@ -32,7 +32,6 @@ import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration
@@ -120,7 +119,6 @@ fun TimelineItemEventRow(
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
@@ -196,7 +194,6 @@ fun TimelineItemEventRow(
TimelineItemEventRowContent(
event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onContentClick,
@@ -230,7 +227,6 @@ fun TimelineItemEventRow(
TimelineItemEventRowContent(
event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onContentClick,
@@ -281,12 +277,10 @@ private fun SwipeSensitivity(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
isHighlighted: Boolean,
timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource,
onContentClick: () -> Unit,
@@ -340,7 +334,6 @@ private fun TimelineItemEventRowContent(
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
)
MessageEventBubble(

View File

@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,6 +20,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
@@ -48,7 +49,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TimelineItemRow(
timelineItem: TimelineItem,
@@ -113,7 +113,6 @@ internal fun TimelineItemRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
@@ -140,6 +139,8 @@ internal fun TimelineItemRow(
} else {
timelineItem.safeSenderName
}
// For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent
}
// Custom clickable that applies over the whole item for accessibility
.then(
@@ -157,7 +158,6 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,

View File

@@ -40,7 +40,6 @@ fun TimelineItemStateEventRow(
event: TimelineItem.Event,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
@@ -60,7 +59,6 @@ fun TimelineItemStateEventRow(
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
@@ -107,7 +105,6 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
isHighlighted = false,
onClick = {},
onLongClick = {},
onReadReceiptsClick = {},

View File

@@ -41,7 +41,16 @@ fun TimelineItemVirtualRow(
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
TimelineItemRoomBeginningModel -> {
TimelineItemRoomBeginningView(
predecessorRoom = timelineRoomInfo.predecessorRoom,
roomName = timelineRoomInfo.name,
isDm = timelineRoomInfo.isDm,
onPredecessorRoomClick = { roomId ->
eventSink(TimelineEvents.NavigateToRoom(roomId))
},
)
}
is TimelineItemLoadingIndicatorModel -> {
TimelineLoadingMoreIndicator(virtual.model.direction)
val latestEventSink by rememberUpdatedState(eventSink)

View File

@@ -22,7 +22,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.TextUnit
@@ -30,11 +29,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EmojiItem(
@@ -49,11 +48,10 @@ fun EmojiItem(
} else {
Color.Transparent
}
val description = if (isSelected) {
stringResource(id = CommonStrings.a11y_remove_reaction_with, item.unicode)
} else {
stringResource(id = CommonStrings.a11y_react_with, item.unicode)
}
val description = a11yReactionAction(
emoji = item.unicode,
userAlreadyReacted = isSelected,
)
Box(
modifier = modifier
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)

View File

@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@@ -57,7 +56,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,

View File

@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@@ -39,7 +38,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemStickerView(
content: TimelineItemStickerContent,

View File

@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -67,7 +66,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -45,12 +46,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -140,9 +146,7 @@ private fun ReactionSummaryViewContent(
HorizontalPager(state = pagerState) { page ->
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(summary.reactions[page].senders) { sender ->
val user = sender.user ?: MatrixUser(userId = sender.senderId)
SenderRow(
avatarData = user.getAvatarData(AvatarSize.UserListItem),
name = user.displayName ?: user.userId.value,
@@ -166,21 +170,32 @@ private fun AggregatedReactionButton(
} else {
Color.Transparent
}
val textColor = if (isHighlighted) {
MaterialTheme.colorScheme.inversePrimary
} else {
ElementTheme.colors.textPrimary
}
val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50))
val a11yText = a11yReactionDetails(
emoji = reaction.key,
userAlreadyReacted = reaction.isHighlighted,
reactionCount = reaction.count,
)
Surface(
modifier = Modifier
.background(buttonColor, roundedCornerShape)
.clip(roundedCornerShape)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 12.dp),
color = buttonColor
.padding(vertical = 8.dp, horizontal = 12.dp)
.selectable(
selected = isHighlighted,
role = Role.Tab,
onClick = onClick,
)
.clearAndSetSemantics {
contentDescription = a11yText
},
color = buttonColor,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -230,7 +245,8 @@ private fun SenderRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp)
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically
) {
Avatar(avatarData)

View File

@@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.timeline.components.virtual
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,44 +20,83 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
@Composable
fun TimelineItemRoomBeginningView(
roomName: String?,
predecessorRoom: PredecessorRoom?,
isDm: Boolean,
onPredecessorRoomClick: (RoomId) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
Column(
modifier = modifier.fillMaxWidth()
) {
val text = if (roomName == null) {
stringResource(id = R.string.screen_room_timeline_beginning_of_room_no_name)
} else {
stringResource(id = R.string.screen_room_timeline_beginning_of_room, roomName)
if (predecessorRoom != null) {
ComposerAlertMolecule(
avatar = null,
content = stringResource(R.string.screen_room_timeline_upgraded_room_message).toAnnotatedString(),
onSubmitClick = { onPredecessorRoomClick(predecessorRoom.roomId) },
isCritical = false,
submitText = stringResource(R.string.screen_room_timeline_upgraded_room_action)
)
}
// Only display for non-DM room
if (!isDm) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
val text = if (roomName == null) {
stringResource(id = R.string.screen_room_timeline_beginning_of_room_no_name)
} else {
stringResource(id = R.string.screen_room_timeline_beginning_of_room, roomName)
}
Text(
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
text = text,
textAlign = TextAlign.Center,
)
}
}
Text(
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
text = text,
textAlign = TextAlign.Center,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemRoomBeginningViewPreview() = ElementPreview {
Column {
TimelineItemRoomBeginningView(
roomName = null,
)
TimelineItemRoomBeginningView(
roomName = "Room Name",
)
Column(verticalArrangement = spacedBy(16.dp)) {
allBooleans.forEach { isDm ->
TimelineItemRoomBeginningView(
predecessorRoom = null,
roomName = null,
isDm = isDm,
onPredecessorRoomClick = {},
)
TimelineItemRoomBeginningView(
predecessorRoom = null,
roomName = "Room Name",
isDm = isDm,
onPredecessorRoomClick = {},
)
TimelineItemRoomBeginningView(
predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org"), EventId("\$eventId:matrix.org")),
roomName = "Room Name",
isDm = isDm,
onPredecessorRoomClick = {},
)
}
}
}

View File

@@ -13,7 +13,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
data class BubbleState(
val groupPosition: TimelineItemGroupPosition,
val isMine: Boolean,
val isHighlighted: Boolean,
val timelineRoomInfo: TimelineRoomInfo,
) {
/** True to cut out the top start corner of the bubble, to give margin for the sender avatar. */

View File

@@ -21,15 +21,11 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
TimelineItemGroupPosition.None,
).map { groupPosition ->
sequenceOf(false, true).map { isMine ->
sequenceOf(false, true).map { isHighlighted ->
aBubbleState(
groupPosition = groupPosition,
isMine = isMine,
isHighlighted = isHighlighted,
)
}
aBubbleState(
groupPosition = groupPosition,
isMine = isMine,
)
}
.flatten()
}
.flatten()
}
@@ -37,11 +33,9 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
internal fun aBubbleState(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First,
isMine: Boolean = false,
isHighlighted: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
) = BubbleState(
groupPosition = groupPosition,
isMine = isMine,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
)

View File

@@ -43,6 +43,10 @@
<item quantity="few">"%1$d změny místnosti"</item>
<item quantity="other">"%1$d změn místnosti"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"Přejít do nové místnosti"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Tato místnost byla nahrazena a již není aktivní"</string>
<string name="screen_room_timeline_upgraded_room_action">"Zobrazit staré zprávy"</string>
<string name="screen_room_timeline_upgraded_room_message">"Tato místnost je pokračováním jiné místnosti"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s a %3$d další"</item>
<item quantity="few">"%1$s, %2$s a %3$d další"</item>

View File

@@ -9,8 +9,8 @@
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
<string name="screen_report_content_block_user_hint">"Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
<string name="screen_report_content_explanation">"Este mensaje se notificará al administrador de su homeserver. No podrán leer ningún mensaje cifrado."</string>
<string name="screen_report_content_block_user_hint">"Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
<string name="screen_report_content_explanation">"Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado."</string>
<string name="screen_report_content_hint">"Motivo para denunciar este contenido"</string>
<string name="screen_room_attachment_source_camera">"Cámara"</string>
<string name="screen_room_attachment_source_camera_photo">"Hacer foto"</string>

View File

@@ -35,6 +35,15 @@
<string name="screen_room_timeline_less_reactions">"Afficher moins"</string>
<string name="screen_room_timeline_message_copied">"Message copié"</string>
<string name="screen_room_timeline_no_permission_to_post">"Vous nêtes pas autorisé à publier dans ce salon"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d membre a réagi avec %2$s"</item>
<item quantity="other">"%1$d membres ont réagi avec %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Vous et %1$d membre avez réagi avec %2$s"</item>
<item quantity="other">"Vous et %1$d membres avez réagi avec %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Vous avez réagi avec %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Afficher moins"</string>
<string name="screen_room_timeline_reactions_show_more">"Afficher plus"</string>
<string name="screen_room_timeline_read_marker_title">"Nouveau"</string>

View File

@@ -35,6 +35,15 @@
<string name="screen_room_timeline_less_reactions">"Vis mindre"</string>
<string name="screen_room_timeline_message_copied">"Melding kopiert"</string>
<string name="screen_room_timeline_no_permission_to_post">"Du har ikke tillatelse til å legge ut innlegg i dette rommet"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d medlem reagerte med %2$s"</item>
<item quantity="other">"%1$d medlemmer reagerte med %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Du og %1$d medlem reagerte med%2$s"</item>
<item quantity="other">"Du og %1$d medlemmer reagerte med%2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Du reagerte med %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Vis mindre"</string>
<string name="screen_room_timeline_reactions_show_more">"Vis mer"</string>
<string name="screen_room_timeline_read_marker_title">"Ny"</string>

View File

@@ -28,13 +28,22 @@
<string name="screen_room_mentions_at_room_title">"Everyone"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
<string name="screen_room_timeline_add_reaction">"Add emoji"</string>
<string name="screen_room_timeline_add_reaction">"Add a reaction"</string>
<string name="screen_room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="screen_room_timeline_legacy_call">"Unsupported call. Ask if the caller can use the new Element X app."</string>
<string name="screen_room_timeline_less_reactions">"Show less"</string>
<string name="screen_room_timeline_message_copied">"Message copied"</string>
<string name="screen_room_timeline_no_permission_to_post">"You do not have permission to post to this room"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d member reacted with %2$s"</item>
<item quantity="other">"%1$d members reacted with %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"You and %1$d member reacted with %2$s"</item>
<item quantity="other">"You and %1$d members reacted with %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"You reacted with %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Show less"</string>
<string name="screen_room_timeline_reactions_show_more">"Show more"</string>
<string name="screen_room_timeline_read_marker_title">"New"</string>
@@ -42,6 +51,10 @@
<item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"Jump to new room"</string>
<string name="screen_room_timeline_tombstoned_room_message">"This room has been replaced and is no longer active"</string>
<string name="screen_room_timeline_upgraded_room_action">"See old messages"</string>
<string name="screen_room_timeline_upgraded_room_message">"This room is a continuation of another room"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s and %3$d other"</item>
<item quantity="other">"%1$s, %2$s and %3$d others"</item>

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.tests.testutils.lambda.lambdaError
@@ -20,6 +21,7 @@ class FakeMessagesNavigator(
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId) -> Unit = { _ -> lambdaError() }
) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo)
@@ -40,4 +42,8 @@ class FakeMessagesNavigator(
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
onPreviewAttachmentLambda(attachments)
}
override fun onNavigateToRoom(roomId: RoomId) {
onNavigateToRoomLambda(roomId)
}
}

View File

@@ -50,6 +50,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -117,9 +119,9 @@ class MessagesPresenterTest {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomName).isEqualTo("")
assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
.isEqualTo(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))
assertThat(initialState.userEventPermissions.canSendMessage).isTrue()
assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue()
@@ -1130,6 +1132,57 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - room with successor room includes successor info in state`() = runTest {
val successorRoomId = RoomId("!successor:server.org")
val successorReason = "This room has been moved to a new location"
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(
successorRoom = SuccessorRoom(
roomId = successorRoomId,
reason = successorReason
)
)
),
typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(joinedRoom = room)
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.successorRoom).isNotNull()
assertThat(initialState.successorRoom?.roomId).isEqualTo(successorRoomId)
assertThat(initialState.successorRoom?.reason).isEqualTo(successorReason)
}
}
@Test
fun `present - room without successor room has null successor info in state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(successorRoom = null)
),
typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(joinedRoom = room)
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.successorRoom).isNull()
}
}
@Test
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
val room = FakeJoinedRoom(

View File

@@ -52,7 +52,9 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -65,6 +67,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.assertNoNodeWithText
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
@@ -107,7 +110,7 @@ class MessagesViewTest {
state = state,
onRoomDetailsClick = callback,
)
rule.onNodeWithText(state.roomName.dataOrNull().orEmpty(), useUnmergedTree = true).performClick()
rule.onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick()
}
}
@@ -391,7 +394,10 @@ class MessagesViewTest {
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText("👍️").onFirst().performClick()
rule.onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performClick()
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventOrTransactionId))
}
@@ -411,7 +417,10 @@ class MessagesViewTest {
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText("👍️").onFirst().performTouchInput { longClick() }
rule.onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️"))
}
@@ -554,6 +563,36 @@ class MessagesViewTest {
rule.onNodeWithText("This is a pinned message").performClick()
eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
}
@Test
fun `clicking on successor room button emits expected event`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
val successorRoomId = RoomId("!successor:server.org")
val state = aMessagesState(
successorRoom = SuccessorRoom(
roomId = successorRoomId,
reason = "This room has been upgraded"
),
timelineState = aTimelineState(eventSink = eventsRecorder)
)
rule.setMessagesView(state = state)
val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action)
// The bottomsheet subcompose seems to make the node to appear twice
rule.onAllNodesWithText(text).onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvents.NavigateToRoom(successorRoomId))
}
@Test
fun `no banner shown when there is no successor room`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
successorRoom = null,
eventSink = eventsRecorder
)
rule.setMessagesView(state = state)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView(

View File

@@ -81,8 +81,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -124,8 +122,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -167,8 +163,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -262,8 +256,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -405,8 +397,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -500,8 +490,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -525,6 +513,49 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for my message no permission`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = false,
canRedactOther = false,
canSendMessage = false,
canSendReaction = false,
canPinUnpin = false,
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a media item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@@ -747,8 +778,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -789,8 +818,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@@ -818,8 +845,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -866,8 +891,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -921,8 +944,6 @@ class ActionListPresenterTest {
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(

View File

@@ -8,12 +8,22 @@
package io.element.android.features.messages.impl.draft
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
class FakeComposerDraftService : ComposerDraftService {
var loadDraftLambda: (RoomId, Boolean) -> ComposerDraft? = { _, _ -> null }
override suspend fun loadDraft(roomId: RoomId, isVolatile: Boolean): ComposerDraft? = loadDraftLambda(roomId, isVolatile)
var loadDraftLambda: (RoomId, ThreadId?, Boolean) -> ComposerDraft? = { _, _, _ -> null }
override suspend fun loadDraft(
roomId: RoomId,
threadRoot: ThreadId?,
isVolatile: Boolean
): ComposerDraft? = loadDraftLambda(roomId, threadRoot, isVolatile)
var saveDraftLambda: (RoomId, ComposerDraft?, Boolean) -> Unit = { _, _, _ -> }
override suspend fun updateDraft(roomId: RoomId, draft: ComposerDraft?, isVolatile: Boolean) = saveDraftLambda(roomId, draft, isVolatile)
var saveDraftLambda: (RoomId, ThreadId?, ComposerDraft?, Boolean) -> Unit = { _, _, _, _ -> }
override suspend fun updateDraft(
roomId: RoomId,
threadRoot: ThreadId?,
draft: ComposerDraft?,
isVolatile: Boolean
) = saveDraftLambda(roomId, threadRoot, draft, isVolatile)
}

View File

@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
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.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -21,27 +22,51 @@ class VolatileComposerDraftStoreTest {
@Test
fun `when storing a non-null draft and then loading it, it's loaded and removed`() = runTest {
val initialDraft = sut.loadDraft(roomId)
val initialDraft = sut.loadDraft(roomId = roomId, threadRoot = null)
assertThat(initialDraft).isNull()
sut.updateDraft(roomId, draft)
sut.updateDraft(roomId = roomId, threadRoot = null, draft = draft)
val loadedDraft = sut.loadDraft(roomId)
val loadedDraft = sut.loadDraft(roomId = roomId, threadRoot = null)
assertThat(loadedDraft).isEqualTo(draft)
val loadedDraftAfter = sut.loadDraft(roomId)
val loadedDraftAfter = sut.loadDraft(roomId = roomId, threadRoot = null)
assertThat(loadedDraftAfter).isNull()
// In thread:
val threadRoot = A_THREAD_ID
val initialThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot)
assertThat(initialThreadDraft).isNull()
sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = draft)
val loadedThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot)
assertThat(loadedThreadDraft).isEqualTo(draft)
val loadedThreadDraftAfter = sut.loadDraft(roomId = roomId, threadRoot = threadRoot)
assertThat(loadedThreadDraftAfter).isNull()
}
@Test
fun `when storing a null draft and then loading it, it's removing the previous one`() = runTest {
val initialDraft = sut.loadDraft(roomId)
val initialDraft = sut.loadDraft(roomId = roomId, threadRoot = null)
assertThat(initialDraft).isNull()
sut.updateDraft(roomId, draft)
sut.updateDraft(roomId, null)
sut.updateDraft(roomId = roomId, threadRoot = null, draft = draft)
sut.updateDraft(roomId = roomId, threadRoot = null, draft = null)
val loadedDraft = sut.loadDraft(roomId)
val loadedDraft = sut.loadDraft(roomId = roomId, threadRoot = null)
assertThat(loadedDraft).isNull()
// In thread:
val threadRoot = A_THREAD_ID
val initialThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot)
assertThat(initialThreadDraft).isNull()
sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = draft)
sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = null)
val loadedThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot)
assertThat(loadedThreadDraft).isNull()
}
}

View File

@@ -37,6 +37,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -178,10 +179,10 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean ->
ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
}
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> }
val draftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
this.saveDraftLambda = updateDraftLambda
@@ -207,23 +208,23 @@ class MessageComposerPresenterTest {
.isCalledExactly(2)
.withSequence(
// Automatic load of draft
listOf(value(A_ROOM_ID), value(false)),
listOf(value(A_ROOM_ID), value(null), value(false)),
// Load of volatile draft when closing edit mode
listOf(value(A_ROOM_ID), value(true))
listOf(value(A_ROOM_ID), value(null), value(true))
)
assert(updateDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), any(), value(true))
.with(value(A_ROOM_ID), value(null), any(), value(true))
}
}
@Test
fun `present - change mode to edit caption`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean ->
ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
}
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> }
val draftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
this.saveDraftLambda = updateDraftLambda
@@ -249,13 +250,13 @@ class MessageComposerPresenterTest {
.isCalledExactly(2)
.withSequence(
// Automatic load of draft
listOf(value(A_ROOM_ID), value(false)),
listOf(value(A_ROOM_ID), value(null), value(false)),
// Load of volatile draft when closing edit mode
listOf(value(A_ROOM_ID), value(true))
listOf(value(A_ROOM_ID), value(null), value(true))
)
assert(updateDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), any(), value(true))
.with(value(A_ROOM_ID), value(null), any(), value(true))
}
}
@@ -303,10 +304,10 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to reply after edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean ->
ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
}
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> }
val draftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
this.saveDraftLambda = updateDraftLambda
@@ -333,11 +334,11 @@ class MessageComposerPresenterTest {
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
assert(updateDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), any(), value(true))
.with(value(A_ROOM_ID), value(null), any(), value(true))
}
}
@@ -1246,7 +1247,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - when there is no draft, nothing is restored`() = runTest {
val loadDraftLambda = lambdaRecorder<RoomId, Boolean, ComposerDraft?> { _, _ -> null }
val loadDraftLambda = lambdaRecorder<RoomId, ThreadId?, Boolean, ComposerDraft?> { _, _, _ -> null }
val composerDraftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
}
@@ -1257,7 +1258,7 @@ class MessageComposerPresenterTest {
awaitFirstItem()
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
ensureAllEventsConsumed()
}
@@ -1265,7 +1266,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - when there is a draft for new message with plain text, it is restored`() = runTest {
val loadDraftLambda = lambdaRecorder<RoomId, Boolean, ComposerDraft?> { _, _ ->
val loadDraftLambda = lambdaRecorder<RoomId, ThreadId?, Boolean, ComposerDraft?> { _, _, _ ->
ComposerDraft(plainText = A_MESSAGE, htmlText = null, draftType = ComposerDraftType.NewMessage)
}
val composerDraftService = FakeComposerDraftService().apply {
@@ -1286,7 +1287,7 @@ class MessageComposerPresenterTest {
}
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
ensureAllEventsConsumed()
}
@@ -1294,7 +1295,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - when there is a draft for new message with rich text, it is restored`() = runTest {
val loadDraftLambda = lambdaRecorder<RoomId, Boolean, ComposerDraft?> { _, _ ->
val loadDraftLambda = lambdaRecorder<RoomId, ThreadId?, Boolean, ComposerDraft?> { _, _, _ ->
ComposerDraft(
plainText = A_MESSAGE,
htmlText = A_MESSAGE,
@@ -1320,14 +1321,14 @@ class MessageComposerPresenterTest {
}
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
ensureAllEventsConsumed()
}
}
@Test
fun `present - when there is a draft for edit, it is restored`() = runTest {
val loadDraftLambda = lambdaRecorder<RoomId, Boolean, ComposerDraft?> { _, _ ->
val loadDraftLambda = lambdaRecorder<RoomId, ThreadId?, Boolean, ComposerDraft?> { _, _, _ ->
ComposerDraft(
plainText = A_MESSAGE,
htmlText = null,
@@ -1354,7 +1355,7 @@ class MessageComposerPresenterTest {
}
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
ensureAllEventsConsumed()
}
@@ -1362,7 +1363,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - when there is a draft for reply, it is restored`() = runTest {
val loadDraftLambda = lambdaRecorder<RoomId, Boolean, ComposerDraft?> { _, _ ->
val loadDraftLambda = lambdaRecorder<RoomId, ThreadId?, Boolean, ComposerDraft?> { _, _, _ ->
ComposerDraft(
plainText = A_MESSAGE,
htmlText = null,
@@ -1400,7 +1401,7 @@ class MessageComposerPresenterTest {
}
assert(loadDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(false))
.with(value(A_ROOM_ID), value(null), value(false))
assert(loadReplyDetailsLambda)
.isCalledOnce()
@@ -1412,7 +1413,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - when save draft event is invoked and composer is empty then service is called with null draft`() = runTest {
val saveDraftLambda = lambdaRecorder<RoomId, ComposerDraft?, Boolean, Unit> { _, _, _ -> }
val saveDraftLambda = lambdaRecorder<RoomId, ThreadId?, ComposerDraft?, Boolean, Unit> { _, _, _, _ -> }
val composerDraftService = FakeComposerDraftService().apply {
this.saveDraftLambda = saveDraftLambda
}
@@ -1425,13 +1426,13 @@ class MessageComposerPresenterTest {
advanceUntilIdle()
assert(saveDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), value(null), value(false))
.with(value(A_ROOM_ID), value(null), value(null), value(false))
}
}
@Test
fun `present - when save draft event is invoked and composer is not empty then service is called`() = runTest {
val saveDraftLambda = lambdaRecorder<RoomId, ComposerDraft?, Boolean, Unit> { _, _, _ -> }
val saveDraftLambda = lambdaRecorder<RoomId, ThreadId?, ComposerDraft?, Boolean, Unit> { _, _, _, _ -> }
val composerDraftService = FakeComposerDraftService().apply {
this.saveDraftLambda = saveDraftLambda
}
@@ -1478,27 +1479,32 @@ class MessageComposerPresenterTest {
.withSequence(
listOf(
value(A_ROOM_ID),
value(null),
value(ComposerDraft(plainText = A_MESSAGE, htmlText = null, draftType = ComposerDraftType.NewMessage)),
value(false)
),
listOf(
value(A_ROOM_ID),
value(null),
value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.NewMessage)),
value(false)
),
listOf(
value(A_ROOM_ID),
value(null),
value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.NewMessage)),
// The volatile draft created when switching to edit mode.
value(true)
),
listOf(
value(A_ROOM_ID),
value(null),
value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.Edit(AN_EVENT_ID))),
value(false)
),
listOf(
value(A_ROOM_ID),
value(null),
// When moving from edit mode, text composer is cleared, so the draft is null
value(null),
value(false)

View File

@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -64,6 +65,7 @@ import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -80,7 +82,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class TimelinePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -705,6 +707,73 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - timeline room info includes predecessor room when room has predecessor`() = runTest {
val predecessorRoomId = RoomId("!predecessor:server.org")
val predecessorEventId = EventId("\$predecessorEvent:server.org")
val predecessorRoom = PredecessorRoom(
roomId = predecessorRoomId,
lastEventId = predecessorEventId
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
predecessorRoomResult = { predecessorRoom }
),
)
val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
assertThat(initialState.timelineRoomInfo.predecessorRoom?.lastEventId).isEqualTo(predecessorEventId)
}
}
@Test
fun `present - timeline room info no predecessor`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
predecessorRoomResult = { null }
),
)
val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull()
}
}
@Test
fun `present - timeline event navigate to room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
),
)
val onNavigateToRoomLambda = lambdaRecorder<RoomId, Unit> {}
val navigator = FakeMessagesNavigator(
onNavigateToRoomLambda = onNavigateToRoomLambda
)
val presenter = createTimelinePresenter(room = room, messagesNavigator = navigator)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.NavigateToRoom(A_ROOM_ID))
assert(onNavigateToRoomLambda)
.isCalledOnce()
.with(
value(A_ROOM_ID)
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}

View File

@@ -47,7 +47,10 @@ class TimelineItemPollViewTest {
)
}
val answer = content.answerItems[answerIndex].answer
rule.onNode(hasText(answer.text)).performClick()
rule.onNode(
matcher = hasText(answer.text),
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id))
}

View File

@@ -20,9 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
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.poll.api.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -32,14 +36,42 @@ import io.element.android.libraries.designsystem.theme.progressIndicatorTrackCol
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PollAnswerView(
answerItem: PollAnswerItem,
modifier: Modifier = Modifier,
) {
val nbVotesText = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount,
)
val a11yText = buildString {
val sentenceDelimiter = stringResource(CommonStrings.common_sentence_delimiter)
append(answerItem.answer.text.removeSuffix("."))
if (answerItem.showVotes) {
append(sentenceDelimiter)
append(nbVotesText)
if (answerItem.votesCount != 0) {
append(sentenceDelimiter)
(answerItem.percentage * 100).toInt().let { percent ->
append(pluralStringResource(R.plurals.a11y_polls_percent_of_total, percent, percent))
}
}
if (answerItem.isWinner) {
append(sentenceDelimiter)
append(stringResource(R.string.a11y_polls_winning_answer))
}
}
}
Row(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.clearAndSetSemantics {
contentDescription = a11yText
},
) {
Icon(
imageVector = if (answerItem.isSelected) {
@@ -70,11 +102,6 @@ internal fun PollAnswerView(
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
)
if (answerItem.showVotes) {
val text = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount
)
Row(
modifier = Modifier.align(Alignment.Bottom),
verticalAlignment = Alignment.CenterVertically,
@@ -87,13 +114,13 @@ internal fun PollAnswerView(
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = text,
text = nbVotesText,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
Text(
text = text,
text = nbVotesText,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d pour cent du total des votes"</item>
<item quantity="other">"%1$d pour cent du total des votes"</item>
</plurals>
<string name="a11y_polls_winning_answer">"Cest la réponse gagnante"</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d prosent av totalt antall stemmer"</item>
<item quantity="other">"%1$d prosent av totalt antall stemmer"</item>
</plurals>
<string name="a11y_polls_winning_answer">"Dette er vinnersvaret"</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item>
</plurals>
<string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources>

View File

@@ -143,7 +143,7 @@ fun CreatePollView(
trailingContent = ListItemContent.Custom {
Icon(
imageVector = CompoundIcons.Delete(),
contentDescription = null,
contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
modifier = Modifier.clickable(answer.canDelete) {
state.eventSink(CreatePollEvents.RemoveAnswer(index))
},

View File

@@ -8,7 +8,6 @@
package io.element.android.features.poll.impl.history
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -30,6 +29,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -52,7 +53,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PollHistoryView(
state: PollHistoryState,
@@ -179,7 +180,9 @@ private fun PollHistoryList(
if (pollHistoryItems.isEmpty()) {
item {
Column(
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
modifier = Modifier
.fillParentMaxSize()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -192,7 +195,9 @@ private fun PollHistoryList(
text = emptyStringResource,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp, horizontal = 16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
textAlign = TextAlign.Center,
)
@@ -228,7 +233,10 @@ private fun PollHistoryItemRow(
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
modifier = modifier.semantics(mergeDescendants = true) {
// Allow the answers to be traversed by Talkback
isTraversalGroup = true
},
border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary),
shape = RoundedCornerShape(size = 12.dp)
) {

View File

@@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Masquer les votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Vos modifications nont pas été enregistrées. Êtes-vous certain de vouloir quitter ?"</string>
<string name="screen_create_poll_delete_option_a11y">"Supprimer loption %1$s"</string>
<string name="screen_create_poll_question_desc">"Question ou sujet"</string>
<string name="screen_create_poll_question_hint">"Quel est le sujet du sondage ?"</string>
<string name="screen_create_poll_title">"Créer un sondage"</string>

View File

@@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Skjul stemmer"</string>
<string name="screen_create_poll_answer_hint">"Alternativ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?"</string>
<string name="screen_create_poll_delete_option_a11y">"Slett alternativet %1$s"</string>
<string name="screen_create_poll_question_desc">"Spørsmål eller emne"</string>
<string name="screen_create_poll_question_hint">"Hva handler avstemningen om?"</string>
<string name="screen_create_poll_title">"Opprett avstemning"</string>

View File

@@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Hide votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
<string name="screen_create_poll_delete_option_a11y">"Delete option %1$s"</string>
<string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string>

View File

@@ -131,7 +131,10 @@ class PollHistoryViewTest {
rule.setPollHistoryViewView(
state = state,
)
rule.onNodeWithText(answer.text).performClick()
rule.onNodeWithText(
text = answer.text,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(
PollHistoryEvents.SelectPollAnswer(eventId, answer.id)
)

View File

@@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
@@ -26,14 +27,17 @@ data class AdvancedSettingsState(
enum class ThemeOption : DropdownOption {
System {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_system)
},
Dark {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_dark)
},
Light {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_light)
}
}

View File

@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@@ -149,7 +150,7 @@ private fun NotificationSettingsContentView(
Text(stringResource(R.string.full_screen_intent_banner_message))
},
onClick = {
state.fullScreenIntentPermissionsState.openFullScreenIntentSettings()
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
}
)
}

View File

@@ -16,7 +16,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -97,7 +97,7 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle)
},
leadingContent = ListItemContent.Custom {
CompositeAvatar(
RoomAvatar(
avatarData = summary.avatarData,
heroes = summary.heroesAvatar,
)

View File

@@ -59,6 +59,7 @@ class DefaultClearCacheUseCase @Inject constructor(
seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
pushService.resetBatteryOptimizationState()
// Ensure the app is restarted
defaultCacheService.onClearedCache(matrixClient.sessionId)
}

View File

@@ -8,14 +8,22 @@
<string name="screen_advanced_settings_element_call_base_url">"URL base personalizada de Element Call"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Define una URL base personalizada para Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL no válida, asegúrate de incluir el protocolo (http/https) y la dirección correcta."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Ocultar avatares en las invitaciones a salas"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Ocultar vistas previas de multimedia en la cronología"</string>
<string name="screen_advanced_settings_media_compression_description">"Sube fotos y vídeos más rápido y reduce el uso de datos"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimizar la calidad de los medios"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderación y seguridad"</string>
<string name="screen_advanced_settings_push_provider_android">"Proveedor de notificaciones push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Desactiva el editor de texto enriquecido para escribir Markdown manualmente."</string>
<string name="screen_advanced_settings_send_read_receipts">"Confirmaciones de lectura"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Si se desactiva, las confirmaciones de lectura no se enviarán a nadie. Seguirás recibiendo confirmaciones de lectura de otros usuarios."</string>
<string name="screen_advanced_settings_share_presence">"Compartir presencia"</string>
<string name="screen_advanced_settings_share_presence_description">"Si se desactiva, no podrás enviar ni recibir confirmaciones de lectura ni notificaciones de escritura."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Ocultar siempre"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Mostrar siempre"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"En las salas privadas"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Siempre se puede mostrar un ítem multimedia oculto pulsando sobre él"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Mostrar multimedia en la cronología"</string>
<string name="screen_advanced_settings_view_source_description">"Habilita la opción para ver el contenido en bruto del mensaje en la cronología."</string>
<string name="screen_blocked_users_empty">"No tienes usuarios bloqueados"</string>
<string name="screen_blocked_users_unblock_alert_action">"Desbloquear"</string>
@@ -45,7 +53,7 @@ Si continúas, es posible que algunos de tus ajustes cambien."</string>
<string name="screen_notification_settings_failed_fixing_configuration">"La configuración no se ha corregido, por favor inténtalo de nuevo."</string>
<string name="screen_notification_settings_group_chats">"Chats grupales"</string>
<string name="screen_notification_settings_invite_for_me_label">"Invitaciones"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Tu servidor principal no admite esta opción en salas cifradas, puede que no recibas notificaciones en algunas salas."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Tu servidor base no admite esta opción en salas cifradas, puede que no recibas notificaciones de algunas salas."</string>
<string name="screen_notification_settings_mentions_section_title">"Menciones"</string>
<string name="screen_notification_settings_mode_all">"Todos"</string>
<string name="screen_notification_settings_mode_mentions">"Menciones"</string>
@@ -55,6 +63,7 @@ Si continúas, es posible que algunos de tus ajustes cambien."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"ajustes del sistema"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notificaciones del sistema desactivadas"</string>
<string name="screen_notification_settings_title">"Notificaciones"</string>
<string name="troubleshoot_notifications_entry_point_push_history_title">"Historial de notificaciones push"</string>
<string name="troubleshoot_notifications_entry_point_section">"Solucionar problemas"</string>
<string name="troubleshoot_notifications_entry_point_title">"Solucionar problemas con las notificaciones"</string>
</resources>

View File

@@ -23,6 +23,7 @@
<string name="screen_advanced_settings_show_media_timeline_always_show">"Всегда показывать"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"В личных комнатах"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Скрытый медиафайл всегда можно отобразить, нажав на него."</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Показать медиафайлы в хронологии"</string>
<string name="screen_advanced_settings_view_source_description">"Включить опцию просмотра источника сообщения в ленте."</string>
<string name="screen_blocked_users_empty">"У вас нет заблокированных пользователей"</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблокировать"</string>
@@ -62,6 +63,7 @@
<string name="screen_notification_settings_system_notifications_action_required_content_link">"настройки системы"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Системные уведомления выключены"</string>
<string name="screen_notification_settings_title">"Уведомления"</string>
<string name="troubleshoot_notifications_entry_point_push_history_title">"История уведомлений"</string>
<string name="troubleshoot_notifications_entry_point_section">"Устранение неполадок"</string>
<string name="troubleshoot_notifications_entry_point_title">"Уведомления об устранении неполадок"</string>
</resources>

View File

@@ -46,8 +46,10 @@ class DefaultClearCacheUseCaseTest {
resetLambda = resetFtueLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val resetBatteryOptimizationStateResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
resetBatteryOptimizationStateResult = resetBatteryOptimizationStateResult,
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
@@ -68,6 +70,7 @@ class DefaultClearCacheUseCaseTest {
resetFtueLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
resetBatteryOptimizationStateResult.assertions().isCalledOnce()
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()

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