Merge branch 'develop' into renovate/kotlin

This commit is contained in:
Benoit Marty
2024-10-21 08:44:21 +02:00
committed by GitHub
363 changed files with 3030 additions and 2267 deletions

View File

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

View File

@@ -1,5 +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">"Αποσύνδεση &amp;amp; Αναβάθμιση"</string>
<string name="banner_migrate_to_native_sliding_sync_action">"Αποσύνδεση &amp; Αναβάθμιση"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."</string>
</resources>

View File

@@ -1,5 +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">"Выйти и обновить"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш homeserver больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."</string>
</resources>

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string>
<string name="screen_analytics_settings_help_us_improve">"Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее."</string>
<string name="screen_analytics_settings_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"здесь"</string>
<string name="screen_analytics_settings_share_data">"Делитесь данными аналитики"</string>
<string name="screen_analytics_settings_share_data">"Отправлять аналитические данные"</string>
</resources>

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Мы не будем записывать или профилировать какие-либо персональные данные"</string>
<string name="screen_analytics_prompt_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string>
<string name="screen_analytics_prompt_help_us_improve">"Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее."</string>
<string name="screen_analytics_prompt_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"здесь"</string>
<string name="screen_analytics_prompt_settings">"Вы можете отключить эту функцию в любое время"</string>

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ internal fun aCallScreenState(
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
webViewError: String? = null,
userAgent: String = "",
isCallActive: Boolean = true,
isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState {
@@ -31,6 +32,7 @@ internal fun aCallScreenState(
urlState = urlState,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Калі ласка, пацвердзіце, што вы хочаце дэактываваць свой уліковы запіс. Гэта дзеянне нельга адмяніць."</string>
<string name="screen_deactivate_account_delete_all_messages">"Выдаліць усе мае паведамленні"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Увага: будучыя карыстальнікі могуць бачыць няпоўныя размовы."</string>
<string name="screen_deactivate_account_description_bold_part">"незваротны"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Назаўсёды адключыць"</string>
<string name="screen_deactivate_account_list_item_2">"Выдаліць вас з усіх чатаў."</string>
<string name="screen_deactivate_account_list_item_3">"Выдаліце інфармацыю аб сваім уліковым запісе з нашага сервера ідэнтыфікацыі."</string>
<string name="screen_deactivate_account_title">"Дэактываваць уліковы запіс"</string>
</resources>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
<string name="screen_notification_optin_title">"Разрешите отправку уведомлений и ни одно сообщение не будет пропущено"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
@@ -33,7 +34,6 @@ import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.util.Optional
class AcceptDeclineInvitePresenterTest {
@get:Rule
@@ -46,7 +46,6 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state ->
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
}
}
}
@@ -61,17 +60,13 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData)
)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.of(inviteData))
assertThat(state.declineAction).isInstanceOf(AsyncAction.Confirming::class.java)
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink(
InternalAcceptDeclineInviteEvents.CancelDeclineInvite
)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@@ -93,22 +88,20 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData)
)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId)
)
}
skipItems(2)
assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(
InternalAcceptDeclineInviteEvents.DismissDeclineError
)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
cancelAndConsumeRemainingEvents()
@@ -141,13 +134,13 @@ class AcceptDeclineInvitePresenterTest {
AcceptDeclineInviteEvents.DeclineInvite(inviteData)
)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId)
)
}
skipItems(2)
assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
}
@@ -173,7 +166,6 @@ class AcceptDeclineInvitePresenterTest {
)
}
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
}
awaitItem().also { state ->
@@ -183,7 +175,6 @@ class AcceptDeclineInvitePresenterTest {
)
}
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
cancelAndConsumeRemainingEvents()
@@ -220,7 +211,6 @@ class AcceptDeclineInvitePresenterTest {
)
}
awaitItem().also { state ->
assertThat(state.invite).isEqualTo(Optional.empty<InviteData>())
assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
}
awaitItem().also { state ->

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</string>
<string name="screen_join_room_knock_action">"Send request to join"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>

View File

@@ -9,9 +9,12 @@ package io.element.android.features.joinroom.impl
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.simulateLongTask
class FakeKnockRoom(
var lambda: (RoomId) -> Result<Unit> = { Result.success(Unit) }
) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = lambda(roomId)
override suspend fun invoke(roomId: RoomId) = simulateLongTask {
lambda(roomId)
}
}

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Вы уверены, что хотите покинуть беседу?"</string>
<string name="leave_conversation_alert_subtitle">"Вы уверены, что хотите покинуть беседу? Эта беседа не является общедоступной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string>
<string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является общедоступной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string>
</resources>

View File

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

View File

@@ -96,7 +96,7 @@ fun PinUnlockView(
latestOnSuccessLogout(state.signOutAction.data)
}
}
AsyncAction.Confirming,
is AsyncAction.Confirming,
is AsyncAction.Failure,
AsyncAction.Uninitialized -> Unit
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
@@ -101,6 +103,7 @@ fun MessageEventBubble(
val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
BoxWithConstraints(
modifier = modifier
.graphicsLayer {
@@ -112,7 +115,7 @@ fun MessageEventBubble(
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
x = if (isRtl) size.width else 0f,
y = yOffsetPx,
),
radius = radiusPx,
@@ -129,7 +132,9 @@ fun MessageEventBubble(
.testTag(TestTags.messageBubble)
.widthIn(
min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO).toInt().toDp()
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO)
.toInt()
.toDp()
)
.clip(bubbleShape)
.combinedClickable(

View File

@@ -27,10 +27,10 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
localSendState = LocalEventSendState.Failed.Unknown("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
aTimelineItemEvent().copy(
aTimelineItemEvent(
messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false),
),
aTimelineItemEvent().copy(
aTimelineItemEvent(
messageShield = MessageShield.UnknownDevice(isCritical = true),
),
)

View File

@@ -301,6 +301,8 @@ private fun TimelineItemEventRowContent(
Modifier
.constrainAs(sender) {
top.linkTo(parent.top)
// Required for correct RTL layout
start.linkTo(parent.start)
}
.padding(horizontal = 16.dp)
.zIndex(1f)
@@ -629,7 +631,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,

View File

@@ -38,7 +38,7 @@ internal fun TimelineItemEventRowForDirectRoomPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
aspectRatio = 5f
),
groupPosition = TimelineItemGroupPosition.Last,

View File

@@ -45,7 +45,7 @@ internal fun TimelineItemEventRowShieldPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,
@@ -54,7 +54,7 @@ internal fun TimelineItemEventRowShieldPreview() = ElementPreview {
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,

View File

@@ -49,7 +49,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
event = aTimelineItemEvent(
isMine = it,
timelineItemReactions = aTimelineItemReactions(count = 0),
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
aspectRatio = 2.5f
),
inReplyTo = inReplyToDetails,

View File

@@ -16,13 +16,13 @@ import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomReactionBottomSheet(
state: CustomReactionState,
onSelectEmoji: (UniqueId, Emoji) -> Unit,
onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
@@ -37,7 +37,7 @@ fun CustomReactionBottomSheet(
if (target?.event == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onSelectEmoji(target.event.id, emoji)
onSelectEmoji(target.event.eventOrTransactionId, emoji)
}
}

View File

@@ -63,7 +63,7 @@ fun TimelineItemAudioView(
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.body,
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,

View File

@@ -64,7 +64,7 @@ fun TimelineItemFileView(
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.body,
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,

View File

@@ -91,7 +91,7 @@ fun TimelineItemImageView(
model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
body = content.filename ?: content.body,
fileName = content.filename,
mimeType = content.mimeType,
),
),
@@ -108,7 +108,9 @@ fun TimelineItemImageView(
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
content.formattedCaption?.body
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
@@ -158,9 +160,9 @@ internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
filename = "image.jpg",
body = "A long caption that may wrap into several lines",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
@@ -170,9 +172,9 @@ internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent().copy(
content = aTimelineItemImageContent(
filename = "image.jpg",
body = "Image with null aspectRatio",
caption = "Image with null aspectRatio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,

View File

@@ -43,7 +43,7 @@ fun TimelineItemStickerView(
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val description = content.body.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
Column(
modifier = modifier.semantics { contentDescription = description },
) {
@@ -65,7 +65,7 @@ fun TimelineItemStickerView(
model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
body = content.body,
fileName = content.filename,
mimeType = content.mimeType,
),
),

View File

@@ -76,8 +76,8 @@ fun TimelineItemVideoView(
) {
val containerModifier = if (content.showCaption) {
Modifier
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
} else {
Modifier
}
@@ -93,12 +93,12 @@ fun TimelineItemVideoView(
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(
source = content.thumbnailSource,
kind = MediaRequestData.Kind.File(
body = content.filename ?: content.body,
fileName = content.filename,
mimeType = content.mimeType
)
),
@@ -126,7 +126,9 @@ fun TimelineItemVideoView(
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
content.formattedCaption?.body
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
@@ -178,7 +180,7 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
isMine = isMine,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
body = "A long caption that may wrap into several lines",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
@@ -190,7 +192,7 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
isMine = false,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
body = "Video with null aspect ratio",
caption = "Video with null aspect ratio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,

View File

@@ -89,14 +89,14 @@ fun ReactionSummaryView(
sheetState = sheetState,
modifier = modifier
) {
SheetContent(summary = state.target)
ReactionSummaryViewContent(summary = state.target)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SheetContent(
private fun ReactionSummaryViewContent(
summary: ReactionSummaryState.Summary,
) {
val animationScope = rememberCoroutineScope()
@@ -274,8 +274,8 @@ private fun SenderRow(
@PreviewsDayNight
@Composable
internal fun SheetContentPreview(
internal fun ReactionSummaryViewContentPreview(
@PreviewParameter(ReactionSummaryStateProvider::class) state: ReactionSummaryState
) = ElementPreview {
SheetContent(summary = state.target as ReactionSummaryState.Summary)
ReactionSummaryViewContent(summary = state.target as ReactionSummaryState.Summary)
}

View File

@@ -84,9 +84,9 @@ class TimelineItemContentMessageFactory @Inject constructor(
is ImageMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
body = messageType.body.trimEnd(),
formatted = messageType.formatted,
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -95,13 +95,15 @@ class TimelineItemContentMessageFactory @Inject constructor(
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty()
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
)
}
is StickerMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
body = messageType.body.trimEnd(),
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -110,7 +112,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
)
}
is LocationMessageType -> {
@@ -136,9 +138,9 @@ class TimelineItemContentMessageFactory @Inject constructor(
is VideoMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
body = messageType.body.trimEnd(),
formatted = messageType.formatted,
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -148,17 +150,19 @@ class TimelineItemContentMessageFactory @Inject constructor(
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename),
)
}
is AudioMessageType -> {
TimelineItemAudioContent(
body = messageType.body.trimEnd(),
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename),
)
}
is VoiceMessageType -> {
@@ -166,7 +170,9 @@ class TimelineItemContentMessageFactory @Inject constructor(
true -> {
TimelineItemVoiceContent(
eventId = eventId,
body = messageType.body.trimEnd(),
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -175,20 +181,24 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
false -> {
TimelineItemAudioContent(
body = messageType.body.trimEnd(),
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename),
)
}
}
}
is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent(
body = messageType.body.trimEnd(),
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),

View File

@@ -33,7 +33,9 @@ class TimelineItemContentStickerFactory @Inject constructor(
val aspectRatio = aspectRatioOf(content.info.width, content.info.height)
return TimelineItemStickerContent(
body = content.body,
filename = content.filename,
caption = content.body,
formattedCaption = null,
mediaSource = content.source,
thumbnailSource = content.info.thumbnailSource,
mimeType = content.info.mimetype ?: MimeTypes.OctetStream,
@@ -42,7 +44,7 @@ class TimelineItemContentStickerFactory @Inject constructor(
height = content.info.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(content.info.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(content.body)
fileExtension = fileExtensionExtractor.extractFromName(content.filename)
)
}
}

View File

@@ -85,9 +85,9 @@ class TimelineItemEventFactory @AssistedInject constructor(
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfoProvider = currentTimelineItem.event.debugInfoProvider,
origin = currentTimelineItem.event.origin,
messageShield = currentTimelineItem.event.messageShieldProvider.getShield(strict = false)
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider,
)
}

View File

@@ -17,10 +17,13 @@ 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.timeline.item.event.EventDebugInfoProvider
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.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -74,9 +77,9 @@ sealed interface TimelineItem {
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?,
val isThreaded: Boolean,
val debugInfoProvider: EventDebugInfoProvider,
val origin: TimelineItemEventOrigin?,
val messageShield: MessageShield?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
@@ -90,7 +93,14 @@ sealed interface TimelineItem {
val isRemote = eventId != null
val debugInfo = debugInfoProvider.get()
val eventOrTransactionId: EventOrTransactionId
get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)
// No need to be lazy here?
val messageShield: MessageShield? = messageShieldProvider(strict = false)
val debugInfo: TimelineItemDebugInfo
get() = timelineItemDebugInfoProvider()
}
@Immutable

View File

@@ -8,17 +8,20 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import kotlin.time.Duration
data class TimelineItemAudioContent(
val body: String,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
val fileExtensionAndSize =
formatFileExtensionAndSize(
fileExtension,

View File

@@ -22,8 +22,10 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
body = fileName,
mimeType = MimeTypes.Pdf,
filename = fileName,
caption = null,
formattedCaption = null,
mimeType = MimeTypes.Mp3,
formattedFileSize = "100kB",
fileExtension = "mp3",
duration = 100.milliseconds,

View File

@@ -8,12 +8,23 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
@Immutable
sealed interface TimelineItemEventContent {
val type: String
}
@Immutable
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
val filename: String
val caption: String?
val formattedCaption: FormattedBody?
val bestDescription: String
get() = caption ?: filename
}
/**
* Only text based content can be copied.
*/

View File

@@ -8,16 +8,19 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
data class TimelineItemFileContent(
val body: String,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val fileSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemFileContent"
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)

View File

@@ -20,8 +20,12 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
)
}
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName,
fun aTimelineItemFileContent(
fileName: String = "A file.pdf",
) = TimelineItemFileContent(
filename = fileName,
caption = null,
formattedCaption = null,
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,

View File

@@ -12,9 +12,9 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
data class TimelineItemImageContent(
val body: String,
val formatted: FormattedBody?,
val filename: String?,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
@@ -24,11 +24,10 @@ data class TimelineItemImageContent(
val width: Int?,
val height: Int?,
val aspectRatio: Float?
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"
val showCaption = filename != null && filename != body
val caption = if (showCaption) body else ""
val showCaption = caption != null
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
mediaSource

View File

@@ -23,12 +23,14 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
}
fun aTimelineItemImageContent(
aspectRatio: Float = 0.5f,
aspectRatio: Float? = 0.5f,
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
) = TimelineItemImageContent(
body = "a body",
formatted = null,
filename = null,
filename = filename,
caption = caption,
formattedCaption = null,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,

View File

@@ -8,9 +8,12 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
data class TimelineItemStickerContent(
val body: String,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
@@ -20,7 +23,7 @@ data class TimelineItemStickerContent(
val width: Int?,
val height: Int?,
val aspectRatio: Float?
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemStickerContent"
/* Stickers are supposed to be small images so

View File

@@ -26,7 +26,9 @@ fun aTimelineItemStickerContent(
aspectRatio: Float = 0.5f,
blurhash: String? = A_BLUR_HASH,
) = TimelineItemStickerContent(
body = "a body",
filename = "a sticker.gif",
caption = "a body",
formattedCaption = null,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,

View File

@@ -12,9 +12,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import kotlin.time.Duration
data class TimelineItemVideoContent(
val body: String,
val formatted: FormattedBody?,
val filename: String?,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val duration: Duration,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -25,9 +25,8 @@ data class TimelineItemVideoContent(
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"
val showCaption = filename != null && filename != body
val caption = if (showCaption) body else ""
val showCaption = caption != null
}

View File

@@ -27,9 +27,9 @@ fun aTimelineItemVideoContent(
aspectRatio: Float = 0.5f,
blurhash: String? = A_BLUR_HASH,
) = TimelineItemVideoContent(
body = "Video.mp4",
formatted = null,
filename = null,
filename = "Video.mp4",
caption = null,
formattedCaption = null,
thumbnailSource = null,
blurHash = blurhash,
aspectRatio = aspectRatio,

View File

@@ -9,16 +9,19 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
data class TimelineItemVoiceContent(
val eventId: EventId?,
val body: String,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val waveform: ImmutableList<Float>,
) : TimelineItemEventContent {
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemAudioContent"
}

View File

@@ -35,17 +35,21 @@ open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineI
}
fun aTimelineItemVoiceContent(
eventId: String? = "\$anEventId",
body: String = "body doesn't really matter for a voice message",
eventId: EventId? = EventId("\$anEventId"),
filename: String = "filename doesn't really matter for a voice message",
caption: String? = "body doesn't really matter for a voice message",
duration: Duration = 61_000.milliseconds,
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
mimeType: String = MimeTypes.Ogg,
mediaSource: MediaSource = MediaSource(contentUri),
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
) = TimelineItemVoiceContent(
eventId = eventId?.let { EventId(it) },
body = body,
eventId = eventId,
filename = filename,
caption = caption,
formattedCaption = null,
duration = duration,
mediaSource = MediaSource(contentUri),
mediaSource = mediaSource,
mimeType = mimeType,
waveform = waveform.toPersistentList(),
)

View File

@@ -35,12 +35,12 @@ interface VoiceMessageMediaRepo {
*
* @param mediaSource the media source of the voice message.
* @param mimeType the mime type of the voice message.
* @param body the body of the voice message.
* @param filename the filename of the voice message.
*/
fun create(
mediaSource: MediaSource,
mimeType: String?,
body: String?,
filename: String?,
): VoiceMessageMediaRepo
}
@@ -61,7 +61,7 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
private val matrixMediaLoader: MatrixMediaLoader,
@Assisted private val mediaSource: MediaSource,
@Assisted("mimeType") private val mimeType: String?,
@Assisted("body") private val body: String?,
@Assisted("filename") private val filename: String?,
) : VoiceMessageMediaRepo {
@ContributesBinding(RoomScope::class)
@AssistedFactory
@@ -69,7 +69,7 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
override fun create(
mediaSource: MediaSource,
@Assisted("mimeType") mimeType: String?,
@Assisted("body") body: String?,
@Assisted("filename") filename: String?,
): DefaultVoiceMessageMediaRepo
}
@@ -79,7 +79,7 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
else -> matrixMediaLoader.downloadMediaFile(
source = mediaSource,
mimeType = mimeType,
body = body,
filename = filename,
).mapCatching {
it.use { mediaFile ->
val dest = cachedFile.apply { parentFile?.mkdirs() }

View File

@@ -37,13 +37,13 @@ interface VoiceMessagePlayer {
* @param eventId The eventId of the voice message event.
* @param mediaSource The media source of the voice message.
* @param mimeType The mime type of the voice message.
* @param body The body of the voice message.
* @param filename The filename of the voice message.
*/
fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
body: String?,
filename: String?,
): VoiceMessagePlayer
}
@@ -113,7 +113,7 @@ class DefaultVoiceMessagePlayer(
private val eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
body: String?,
filename: String?,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
@@ -124,21 +124,21 @@ class DefaultVoiceMessagePlayer(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
body: String?,
filename: String?,
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
eventId = eventId,
mediaSource = mediaSource,
mimeType = mimeType,
body = body,
filename = filename,
)
}
private val repo = voiceMessageMediaRepoFactory.create(
mediaSource = mediaSource,
mimeType = mimeType,
body = body
filename = filename,
)
private var internalState = MutableStateFlow(

View File

@@ -59,7 +59,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
eventId = content.eventId,
mediaSource = content.mediaSource,
mimeType = content.mimeType,
body = content.body,
filename = content.filename,
)
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)

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