Merge branch 'release/25.06.3'
This commit is contained in:
17
CHANGES.md
17
CHANGES.md
@@ -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
|
||||
|
||||
5
appnav/src/main/res/values-in/translations.xml
Normal file
5
appnav/src/main/res/values-in/translations.xml
Normal 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 & 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>
|
||||
@@ -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()
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/202506030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202506030.txt
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 à l’appel."</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 l’utilisation d’accessoires Bluetooth dans cette version d’Android. Sélectionnez une autre sortie audio."</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -79,7 +79,7 @@ class JoinRoomFlowNode @AssistedInject constructor(
|
||||
JoinRoomView(
|
||||
state = state,
|
||||
onBackClick = ::navigateUp,
|
||||
onJoinSuccess = ::navigateUp,
|
||||
onJoinSuccess = {},
|
||||
onForgetSuccess = ::navigateUp,
|
||||
onCancelKnockSuccess = {},
|
||||
onKnockSuccess = {},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -106,7 +106,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
renderTypingNotifications = false,
|
||||
typingMembers = persistentListOf(),
|
||||
reserveSpace = false,
|
||||
)
|
||||
),
|
||||
predecessorRoom = room.predecessorRoom(),
|
||||
)
|
||||
}
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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">"C’est la réponse gagnante"</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
8
features/poll/api/src/main/res/values/localazy.xml
Normal file
8
features/poll/api/src/main/res/values/localazy.xml
Normal 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>
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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 n’ont pas été enregistrées. Êtes-vous certain de vouloir quitter ?"</string>
|
||||
<string name="screen_create_poll_delete_option_a11y">"Supprimer l’option %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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user