Merge branch 'develop' into feature/fga/requests_to_join_list

This commit is contained in:
ganfra 2024-12-04 14:24:40 +01:00
commit d57ec1c2f8
412 changed files with 4675 additions and 2105 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.6
- uses: mobile-dev-inc/action-maestro-cloud@v1.9.7
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

@ -16,5 +16,5 @@ object ElementCallConfig {
/**
* The default duration of a ringing call in seconds before it's automatically dismissed.
*/
const val RINGING_CALL_DURATION_SECONDS = 15
const val RINGING_CALL_DURATION_SECONDS = 90
}

View file

@ -11,14 +11,20 @@ import android.graphics.Color
import androidx.annotation.ColorInt
object NotificationConfig {
// TODO EAx Implement and set to true at some point
const val SUPPORT_MARK_AS_READ_ACTION = false
/**
* If set to true, the notification will have a "Mark as read" action.
*/
const val SHOW_MARK_AS_READ_ACTION = true
// TODO EAx Implement and set to true at some point
const val SUPPORT_JOIN_DECLINE_INVITE = false
/**
* If set to true, the notification for invitation will have two actions to accept or decline the invite.
*/
const val SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS = true
// TODO EAx Implement and set to true at some point
const val SUPPORT_QUICK_REPLY_ACTION = false
/**
* If set to true, the notification will have a "Quick reply" action, allow to compose and send a message to the room.
*/
const val SHOW_QUICK_REPLY_ACTION = true
@ColorInt
val NOTIFICATION_ACCENT_COLOR: Int = Color.parseColor("#FF0DBD8B")

View file

@ -20,11 +20,20 @@ import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.NavElements
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.BackStack.State.ACTIVE
import com.bumble.appyx.navmodel.backstack.BackStack.State.CREATED
import com.bumble.appyx.navmodel.backstack.BackStack.State.STASHED
import com.bumble.appyx.navmodel.backstack.BackStackElement
import com.bumble.appyx.navmodel.backstack.BackStackElements
import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
import com.bumble.appyx.navmodel.backstack.operation.Push
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
@ -312,7 +321,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) }
coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
@ -400,6 +409,11 @@ class LoggedInFlowNode @AssistedInject constructor(
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.callback(object : SecureBackupEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
})
.build()
}
NavTarget.Ftue -> {
@ -467,21 +481,21 @@ class LoggedInFlowNode @AssistedInject constructor(
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.RoomList
}
attachChild<RoomFlowNode> {
backstack.push(
NavTarget.Room(
roomIdOrAlias = roomIdOrAlias,
serverNames = serverNames,
trigger = trigger,
initialElement = RoomNavigationTarget.Messages(
focusedEventId = eventId
)
val roomNavTarget = NavTarget.Room(
roomIdOrAlias = roomIdOrAlias,
serverNames = serverNames,
trigger = trigger,
initialElement = RoomNavigationTarget.Messages(
focusedEventId = eventId
)
)
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
}
}
@ -526,3 +540,31 @@ class LoggedInFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
}
@Parcelize
private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room,
val clearBackstack: Boolean,
) : BackStackOperation<LoggedInFlowNode.NavTarget> {
override fun isApplicable(elements: NavElements<LoggedInFlowNode.NavTarget, BackStack.State>) = true
override fun invoke(elements: BackStackElements<LoggedInFlowNode.NavTarget>): BackStackElements<LoggedInFlowNode.NavTarget> {
return if (clearBackstack) {
// Makes sure the room list target is alone in the backstack and stashed
elements.mapNotNull { element ->
if (element.key.navTarget == LoggedInFlowNode.NavTarget.RoomList) {
element.transitionTo(STASHED, this)
} else {
null
}
} + BackStackElement(
key = NavKey(roomTarget),
fromState = CREATED,
targetState = ACTIVE,
operation = this
)
} else {
Push<LoggedInFlowNode.NavTarget>(roomTarget).invoke(elements)
}
}
}

View file

@ -303,6 +303,7 @@ class RootFlowNode @AssistedInject constructor(
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
clearBackstack = true
)
}
is PermalinkData.UserLink -> {
@ -318,7 +319,7 @@ class RootFlowNode @AssistedInject constructor(
.apply {
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias())
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
}
}
}

View file

@ -52,6 +52,10 @@ allprojects {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
exclude("io/element/android/tests/konsist/failures/**")
}
// KtLint
apply {
plugin("org.jlleitschuh.gradle.ktlint")
@ -75,6 +79,7 @@ allprojects {
val generatedPath = "${layout.buildDirectory.asFile.get()}/generated/"
filter {
exclude { element -> element.file.path.contains(generatedPath) }
exclude("io/element/android/tests/konsist/failures/**")
}
}
// Dependency check

View file

@ -66,19 +66,34 @@ class WebViewWidgetMessageInterceptor(
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
onError(error?.description?.toString())
// Only propagate the error if it happens while loading the current page
if (view?.url == request?.url.toString()) {
onError(error?.description.toString())
}
super.onReceivedError(view, request, error)
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}")
onError(errorResponse?.statusCode.toString())
// Only propagate the error if it happens while loading the current page
if (view?.url == request?.url.toString()) {
onError(errorResponse?.statusCode.toString())
}
super.onReceivedHttpError(view, request, errorResponse)
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
Timber.e("onReceivedSslError error: ${error?.primaryError}")
onError(error?.primaryError?.toString())
// Only propagate the error if it happens while loading the current page
if (view?.url == error?.url.toString()) {
onError(error?.toString())
}
super.onReceivedSslError(view, handler, error)
}
}

View file

@ -102,7 +102,10 @@ class CreateRoomDataStore @Inject constructor(
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> config.roomVisibility.copy(roomAddress = RoomAddress.Edited(address))
is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
}
else -> config.roomVisibility
}
)

View file

@ -240,9 +240,9 @@ private fun RoomTopic(
modifier = modifier,
label = stringResource(R.string.screen_create_room_topic_label),
value = topic,
placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChange,
maxLines = 3,
supportingText = stringResource(CommonStrings.common_topic_placeholder),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),

View file

@ -3,11 +3,22 @@
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Chats ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Chat sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (für alle)"</string>
<string name="screen_create_room_private_option_description">"Nur eingeladene Personen haben Zutritt zu diesem Chatroom. Alle Nachrichten sind durchgehend verschlüsselt."</string>
<string name="screen_create_room_private_option_title">"Privater Chatroom"</string>
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chatroom finden.
Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Chatroom"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Jeder kann diesem Chatroom beitreten"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Jemand"</string>
<string name="screen_create_room_room_access_section_header">"Chatroom Zugang"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann darum bitten, dem Chatroom beizutreten, aber ein Administrator oder ein Moderator muss die Anfrage akzeptieren."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Beitritt beantragen"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Einige Zeichen sind nicht erlaubt. Es werden nur Buchstaben, Ziffern und die folgenden Symbole unterstützt: ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"Diese Chatroomadresse existiert bereits. Bitte versuchen Sie, das Adressenfeld des Chatrooms zu bearbeiten oder den Namen des Chatrooms zu ändern"</string>
<string name="screen_create_room_room_address_section_footer">"Damit dieser Chatroom im öffentlichen Chatroomverzeichnis sichtbar ist, benötigen Sie eine Chatroomadresse."</string>
<string name="screen_create_room_room_address_section_title">"Chatroom Adresse"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chatrooms"</string>
<string name="screen_create_room_title">"Raum erstellen"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>

View file

@ -14,7 +14,7 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Anyone can ask to join the room but an administrator or a moderator will have to accept the request"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Ask to join"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"This room address already exists, please try editing the room address field or change the room name"</string>
<string name="screen_create_room_room_address_not_available_error_description">"This room address already exists. Please try editing the room address field or change the room name"</string>
<string name="screen_create_room_room_address_section_footer">"In order for this room to be visible in the public room directory, you will need a room address."</string>
<string name="screen_create_room_room_address_section_title">"Room address"</string>
<string name="screen_create_room_room_name_label">"Room name"</string>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bitte bestätigen Sie, dass Sie Ihr Benutzerkonto deaktivieren möchten. Diese Aktion kann nicht rückgängig gemacht werden."</string>
<string name="screen_deactivate_account_delete_all_messages">"Lösche alle meine Nachrichten"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Warnung: Benutzern werden möglicherweise unvollständige Konversationen angezeigt."</string>
<string name="screen_deactivate_account_description">"Wenn Sie Ihr Konto deaktivieren%1$s, wird es:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s Ihr Konto (Sie können sich nicht erneut anmelden und Ihre ID kann nicht wiederverwendet werden)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Dauerhaft deaktivieren"</string>
<string name="screen_deactivate_account_list_item_2">"Sie werden aus allen Chatrooms entfernt."</string>
<string name="screen_deactivate_account_list_item_3">"Löschen Sie Ihre Kontoinformationen von unserem Identitätsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Gelöschte Nachrichten werden für bereits registrierte Benutzer weiterhin sichtbar sein, wenn sie auch neuen oder nicht registrierten Benutzern nicht mehr zur Verfügung stehen."</string>
<string name="screen_deactivate_account_title">"Benutzerkonto deaktivieren"</string>
</resources>

View file

@ -16,7 +16,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class SharedPreferencesWelcomeScreenState @Inject constructor(
class SharedPreferencesWelcomeScreenStore @Inject constructor(
private val sharedPreferences: SharedPreferences,
) : WelcomeScreenStore {
companion object {

View file

@ -7,7 +7,7 @@
package io.element.android.features.ftue.impl.welcome.state
class InMemoryWelcomeScreenState : WelcomeScreenStore {
class InMemoryWelcomeScreenStore : WelcomeScreenStore {
private var isWelcomeScreenNeeded = true
override fun isWelcomeScreenNeeded(): Boolean {

View file

@ -48,8 +48,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional
private const val MAX_KNOCK_MESSAGE_LENGTH = 500
class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias,

View file

@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
internal const val MAX_KNOCK_MESSAGE_LENGTH = 500
@Immutable
data class JoinRoomState(
val contentState: ContentState,

View file

@ -40,17 +40,6 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
),
aJoinRoomState(
contentState = aLoadedContentState(
joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
" laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
" voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
" non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
numberOfMembers = 888,
)
),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
),
@ -81,6 +70,29 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
isDm = true,
)
),
aJoinRoomState(
contentState = aLoadedContentState(
joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
" laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
" voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
" non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
numberOfMembers = 888,
)
),
aJoinRoomState(
knockMessage = "Let me in please!",
contentState = aLoadedContentState(
joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
" laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
" voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
" non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
numberOfMembers = 888,
)
),
aJoinRoomState(
contentState = aLoadedContentState(
name = "A knocked Room",

View file

@ -390,13 +390,18 @@ private fun DefaultLoadedContent(
)
} else if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) {
Spacer(modifier = Modifier.height(24.dp))
val supportingText = if (knockMessage.isNotEmpty()) {
"${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH"
} else {
stringResource(R.string.screen_join_room_knock_message_description)
}
TextField(
value = knockMessage,
onValueChange = onKnockMessageUpdate,
maxLines = 3,
minLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = stringResource(R.string.screen_join_room_knock_message_description)
supportingText = supportingText
)
}
}

View file

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Anfrage abbrechen"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Ja, abbrechen"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Möchten Sie Ihre Beitrittsanfrage für diesen Chatroom wirklich stornieren?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Beitrittsanfrage stornieren"</string>
<string name="screen_join_room_join_action">"Raum beitreten"</string>
<string name="screen_join_room_knock_action">"Anklopfen"</string>
<string name="screen_join_room_knock_message_description">"Nachricht (optional)"</string>
<string name="screen_join_room_knock_sent_description">"Falls Ihre Anfrage, dem Raum beizutreten, akzeptiert wird, werden Sie eine Einladung erhalten."</string>
<string name="screen_join_room_knock_sent_title">"Beitrittsanfrage geschickt"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces werden noch nicht unterstützt"</string>
<string name="screen_join_room_subtitle_knock">"Klopfe an um einen Administrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."</string>

View file

@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrische Authentifizierung"</string>
<string name="screen_app_lock_biometric_unlock">"biometrisches Entsperren"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Mit Biometrie entsperren"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Biometrische Daten bestätigen"</string>
<string name="screen_app_lock_forgot_pin">"PIN vergessen?"</string>
<string name="screen_app_lock_settings_change_pin">"PIN-Code ändern"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Biometrisches Entsperren zulassen"</string>

View file

@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"lauthentification biométrique"</string>
<string name="screen_app_lock_biometric_unlock">"déverrouillage biométrique"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirmer la biométrie"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>

View file

@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"биометрическая идентификация"</string>
<string name="screen_app_lock_biometric_unlock">"биометрическая разблокировка"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string>
<string name="screen_app_lock_confirm_biometric_authentication_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_enable_biometric_unlock">"Разрешить биометрическую разблокировку"</string>

View file

@ -60,6 +60,7 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Neues Gerät verknüpfen\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scanne den QR-Code mit diesem Gerät"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Nur verfügbar für den Fall dass Ihr Kontoanbieter dies unterstützt."</string>
<string name="screen_qr_code_login_initial_state_title">"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>

View file

@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -51,6 +52,7 @@ 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.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
@ -66,8 +68,8 @@ import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.services.analytics.api.AnalyticsService
@ -86,6 +88,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
@ -228,14 +231,22 @@ class MessagesFlowNode @AssistedInject constructor(
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(
val params = MediaViewerEntryPoint.Params(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canDownload = true,
canShare = true,
)
createNode<MediaViewerNode>(buildContext, listOf(inputs))
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
@ -319,100 +330,93 @@ class MessagesFlowNode @AssistedInject constructor(
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
return when (event.content) {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemStickerContent -> {
/* Sticker may have an empty url and no thumbnail
if encrypted on certain bridges */
if (event.content.preferredMediaSource != null) {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.preferredMediaSource,
event.content.preferredMediaSource?.let { preferredMediaSource ->
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = preferredMediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
} else {
false
}
}
is TimelineItemVideoContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.videoSource,
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemFileContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.fileSource,
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemAudioContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
)
overlay.show(navTarget)
true
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
}
else -> null
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
overlay.show(navTarget)
true
}
is NavTarget.LocationViewer -> {
backstack.push(navTarget)
true
}
else -> false
}
}
private fun buildMediaViewerNavTarget(
event: TimelineItem.Event,
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = content.filename,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
)
}
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles(currentUserId = room.sessionId)

View file

@ -28,6 +28,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
@ -65,6 +67,7 @@ class MessagesNode @AssistedInject constructor(
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
@ -73,6 +76,7 @@ class MessagesNode @AssistedInject constructor(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(this),
timelinePresenter = timelinePresenterFactory.create(this),
actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
)
private val callbacks = plugins<Callback>()

View file

@ -28,9 +28,8 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@ -93,7 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
actionListPresenterFactory: ActionListPresenter.Factory,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
@ -110,14 +109,13 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
) : Presenter<MessagesState> {
private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
@AssistedFactory
interface Factory {
fun create(
navigator: MessagesNavigator,
composerPresenter: Presenter<MessageComposerState>,
timelinePresenter: Presenter<TimelineState>,
actionListPresenter: Presenter<ActionListState>,
): MessagesPresenter
}
@ -272,7 +270,8 @@ class MessagesPresenter @AssistedInject constructor(
timelineState: TimelineState,
) = launch {
when (action) {
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
@ -403,26 +402,28 @@ class MessagesPresenter @AssistedInject constructor(
}
}
private fun handleActionAddCaption(
private suspend fun handleActionAddCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = "",
showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionEditCaption(
private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
@ -488,11 +489,17 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemStateContent -> event.content.body
else -> return
}
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
}
}
private suspend fun handleCopyCaption(event: TimelineItem.Event) {
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
}

View file

@ -21,6 +21,7 @@ import dagger.assisted.AssistedInject
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
@ -37,6 +38,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -60,6 +63,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
private val featureFlagService: FeatureFlagService,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@ -67,6 +71,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
}
private val comparator = TimelineItemActionComparator()
@Composable
override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope()
@ -134,7 +140,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
private fun buildActions(
private suspend fun buildActions(
timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
@ -142,7 +148,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
isEventPinned: Boolean,
): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildList {
return buildSet {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread)
@ -157,7 +163,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
// Caption
if (timelineItem.content.caption == null) {
add(TimelineItemAction.AddCaption)
if (featureFlagService.isFeatureEnabled(FeatureFlags.MediaCaptionCreation)) {
add(TimelineItemAction.AddCaption)
}
} else {
add(TimelineItemAction.EditCaption)
add(TimelineItemAction.RemoveCaption)
@ -178,7 +186,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
add(TimelineItemAction.CopyText)
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
add(TimelineItemAction.CopyCaption)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
@ -194,6 +204,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
.postFilter(timelineItem.content)
.sortedWith(comparator)
.let(postProcessor::process)
}
}
@ -201,7 +212,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
/**
* Post filter the actions based on the content of the event.
*/
private fun List<TimelineItemAction>.postFilter(content: TimelineItemEventContent): List<TimelineItemAction> {
private fun Iterable<TimelineItemAction>.postFilter(content: TimelineItemEventContent): Iterable<TimelineItemAction> {
return filter { action ->
when (content) {
is TimelineItemCallNotifyContent,

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@ -22,7 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
override val values: Sequence<ActionListState>
@ -50,7 +51,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption,
),
)
),
anActionListState(
@ -61,7 +64,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption,
),
)
),
anActionListState(
@ -72,7 +77,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = null,
),
)
),
anActionListState(
@ -83,18 +90,22 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption,
),
)
),
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemVoiceContent(),
content = aTimelineItemVoiceContent(caption = null),
timelineItemReactions = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = null,
),
)
),
anActionListState(
@ -161,27 +172,31 @@ fun anActionListState(
eventSink = eventSink
)
fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
fun aTimelineItemActionList(
copyAction: TimelineItemAction? = TimelineItemAction.CopyText
): ImmutableList<TimelineItemAction> {
return setOfNotNull(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
copyAction,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
TimelineItemAction.ViewSource,
)
.sortedWith(TimelineItemActionComparator())
.toPersistentList()
}
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
return setOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Copy,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
.sortedWith(TimelineItemActionComparator())
.toPersistentList()
}

View file

@ -22,7 +22,8 @@ sealed class TimelineItemAction(
) {
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
data object CopyText : TimelineItemAction(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy)
data object CopyCaption : TimelineItemAction(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)

View file

@ -0,0 +1,37 @@
/*
* 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.messages.impl.actionlist.model
class TimelineItemActionComparator : Comparator<TimelineItemAction> {
// See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
private val orderedList = listOf(
TimelineItemAction.EndPoll,
TimelineItemAction.ViewInTimeline,
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.AddCaption,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
val index1 = orderedList.indexOf(o1)
val index2 = orderedList.indexOf(o2)
return index1.compareTo(index2)
}
}

View file

@ -20,12 +20,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@ContributesNode(RoomScope::class)
class AttachmentsPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: AttachmentsPreviewPresenter.Factory,
private val localMediaRenderer: LocalMediaRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs
@ -46,6 +48,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present()
AttachmentsPreviewView(
state = state,
localMediaRenderer = localMediaRenderer,
modifier = modifier
)
}

View file

@ -8,7 +8,9 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -19,10 +21,16 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException
@ -39,6 +47,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val featureFlagService: FeatureFlagService,
) : Presenter<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
@ -63,19 +72,64 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
val userSentAttachment = remember { mutableStateOf(false) }
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
preProcessAttachment(
attachment,
mediaUploadInfoState,
)
}
LaunchedEffect(userSentAttachment.value, mediaUploadInfoState.value) {
if (userSentAttachment.value) {
// User confirmed sending the attachment
when (val mediaUploadInfo = mediaUploadInfoState.value) {
is AsyncData.Success -> {
// Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
ongoingSendAttachmentJob.value = coroutineScope.launch {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo.data,
caption = caption,
sendActionState = sendActionState,
)
}
}
is AsyncData.Failure -> {
// Pre-processing has failed, show the error
sendActionState.value = SendActionState.Failure(mediaUploadInfo.error)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> {
// Pre-processing is still in progress, do nothing
}
}
}
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> {
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
attachment = attachment,
caption = caption,
sendActionState = sendActionState,
)
is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch {
val useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
userSentAttachment.value = true
val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue
sendActionState.value = if (instantSending) {
SendActionState.Sending.InstantSending
} else {
SendActionState.Sending.Processing
}
}
AttachmentsPreviewEvents.Cancel -> {
coroutineScope.cancel(attachment)
coroutineScope.cancel(
attachment,
mediaUploadInfoState.value,
sendActionState,
)
}
AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let {
@ -91,60 +145,101 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
attachment = attachment,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.sendAttachment(
private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
caption: String?,
sendActionState: MutableState<SendActionState>,
mediaUploadInfoState: MutableState<AsyncData<MediaUploadInfo>>,
) = launch {
when (attachment) {
is Attachment.Media -> {
sendMedia(
preProcessMedia(
mediaAttachment = attachment,
caption = caption,
sendActionState = sendActionState,
mediaUploadInfoState = mediaUploadInfoState,
)
}
}
}
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaUploadInfoState: MutableState<AsyncData<MediaUploadInfo>>,
) {
mediaUploadInfoState.value = AsyncData.Loading()
mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
).fold(
onSuccess = { mediaUploadInfo ->
mediaUploadInfoState.value = AsyncData.Success(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
mediaUploadInfoState.value = AsyncData.Failure(it)
}
}
)
}
private fun CoroutineScope.cancel(
attachment: Attachment,
mediaUploadInfo: AsyncData<MediaUploadInfo>,
sendActionState: MutableState<SendActionState>,
) = launch {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
mediaUploadInfo.dataOrNull()?.let { data ->
cleanUp(data)
}
}
}
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener()
}
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
private fun cleanUp(
mediaUploadInfo: MediaUploadInfo,
) {
mediaUploadInfo.allFiles().forEach { file ->
file.safeDelete()
}
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
sendActionState: MutableState<SendActionState>,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
// Note will not happen if useSendQueue is true
if (context.isActive) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
sendActionState.value = SendActionState.Sending.Processing
mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
mediaSender.sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
progressCallback = progressCallback
).getOrThrow()
}.fold(
onSuccess = {
cleanUp(mediaUploadInfo)
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener()
},
onFailure = { error ->

View file

@ -15,11 +15,10 @@ data class AttachmentsPreviewState(
val attachment: Attachment,
val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val allowCaption: Boolean,
val showCaptionCompatibilityWarning: Boolean,
val eventSink: (AttachmentsPreviewEvents) -> Unit
) {
// Keep the val to eventually set to false for some mimetypes.
val allowCaption: Boolean = true
}
)
@Immutable
sealed interface SendActionState {
@ -27,9 +26,11 @@ sealed interface SendActionState {
@Immutable
sealed interface Sending : SendActionState {
data object InstantSending : Sending
data object Processing : Sending
data class Uploading(val progress: Float) : Sending
}
data class Failure(val error: Throwable) : SendActionState
data object Done : SendActionState
}

View file

@ -10,12 +10,9 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
@ -23,11 +20,11 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf(
anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
anAttachmentsPreviewState(allowCaption = false),
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
)
}
@ -35,11 +32,15 @@ fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
allowCaption: Boolean = true,
showCaptionCompatibilityWarning: Boolean = true,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
sendActionState = sendActionState,
textEditorState = textEditorState,
allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
eventSink = {}
)

View file

@ -8,18 +8,22 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -34,20 +38,20 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.display.TextDisplay
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsPreviewView(
state: AttachmentsPreviewState,
localMediaRenderer: LocalMediaRenderer,
modifier: Modifier = Modifier,
) {
fun postSendAttachment() {
@ -79,9 +83,11 @@ fun AttachmentsPreviewView(
title = {},
)
}
) {
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
@ -99,12 +105,17 @@ private fun AttachmentSendStateView(
onRetryClick: () -> Unit
) {
when (sendActionState) {
is SendActionState.Sending -> {
is SendActionState.Sending.Processing -> {
ProgressDialog(
type = when (sendActionState) {
is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress)
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
},
type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Determinate(sendActionState.progress),
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
@ -124,30 +135,23 @@ private fun AttachmentSendStateView(
@Composable
private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
localMediaRenderer: LocalMediaRenderer,
onSendClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.weight(1f),
contentAlignment = Alignment.Center
) {
when (val attachment = state.attachment) {
is Attachment.Media -> {
val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
)
)
LocalMediaView(
modifier = Modifier.fillMaxSize(),
localMedia = attachment.localMedia,
localMediaViewState = localMediaViewState,
onClick = {}
)
localMediaRenderer.Render(attachment.localMedia)
}
}
}
@ -158,7 +162,6 @@ private fun AttachmentPreviewContent(
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault)
.height(IntrinsicSize.Min)
.align(Alignment.BottomCenter)
.imePadding(),
)
}
@ -174,7 +177,10 @@ private fun AttachmentsPreviewBottomActions(
modifier = modifier,
state = state.textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Attachment(state.allowCaption),
composerMode = MessageComposerMode.Attachment(
allowCaption = state.allowCaption,
showCaptionCompatibilityWarning = state.showCaptionCompatibilityWarning,
),
onRequestFocus = {},
onSendMessage = onSendClick,
showTextFormatting = false,
@ -200,5 +206,15 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
localMediaRenderer = object : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
)
}

View file

@ -19,6 +19,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -35,6 +36,7 @@ class PinnedMessagesListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: PinnedMessagesListPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
@ -47,7 +49,10 @@ class PinnedMessagesListNode @AssistedInject constructor(
fun onForwardEventClick(eventId: EventId)
}
private val presenter = presenterFactory.create(this)
private val presenter = presenterFactory.create(
navigator = this,
actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
)
private val callbacks = plugins<Callback>()
private fun onEventClick(event: TimelineItem.Event) {

View file

@ -23,7 +23,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
@ -64,13 +64,16 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
interface Factory {
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
fun create(
navigator: PinnedMessagesListNavigator,
actionListPresenter: Presenter<ActionListState>,
): PinnedMessagesListPresenter
}
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
@ -79,7 +82,6 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
computeReactions = false,
)
)
private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
@Composable
override fun present(): PinnedMessagesListState {

View file

@ -63,6 +63,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
return TextDisplay.Custom(mentionSpan)
}
},
isEditor = false,
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
).apply {
configureWith(editorStyle)

View file

@ -40,6 +40,15 @@ fun TimelineItemEncryptedView(
UtdCause.UnknownDevice -> {
CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block
}
UtdCause.HistoricalMessage -> {
CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block
}
UtdCause.WithheldUnverifiedOrInsecureDevice -> {
CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block
}
UtdCause.WithheldBySender -> {
CommonStrings.timeline_decryption_failure_unable_to_decrypt to CompoundDrawables.ic_compound_error
}
else -> {
CommonStrings.common_waiting_for_decryption_key to CompoundDrawables.ic_compound_time
}

View file

@ -8,8 +8,10 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
@ -44,14 +46,18 @@ fun TimelineItemInformativeView(
)
)
},
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
resourceId = iconResourceId,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = iconDescription,
modifier = Modifier.size(16.dp)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(20.dp)
) {
Icon(
resourceId = iconResourceId,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = iconDescription,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
fontStyle = FontStyle.Italic,

View file

@ -12,10 +12,10 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.libraries.matrix.api.core.EventId
open class ReactionSummaryStateProvider : PreviewParameterProvider<ReactionSummaryState> {
override val values = sequenceOf(anActionListState())
override val values = sequenceOf(aReactionSummaryState())
}
fun anActionListState(): ReactionSummaryState {
fun aReactionSummaryState(): ReactionSummaryState {
val reactions = aTimelineItemReactions(8, true).reactions
return ReactionSummaryState(
target = ReactionSummaryState.Summary(

View file

@ -147,7 +147,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
@ -186,6 +186,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
)
}
false -> {
@ -211,7 +213,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtension

View file

@ -17,10 +17,10 @@ data class TimelineItemAudioContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
override val mediaSource: MediaSource,
override val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
val fileExtensionAndSize =
formatFileExtensionAndSize(

View file

@ -33,6 +33,24 @@ open class TimelineItemEncryptedContentProvider : PreviewParameterProvider<Timel
utdCause = UtdCause.UnsignedDevice,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessage,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.WithheldUnverifiedOrInsecureDevice,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.WithheldBySender,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.MediaSource
@Immutable
sealed interface TimelineItemEventContent {
@ -26,6 +27,10 @@ sealed interface TimelineItemEventContentWithAttachment :
val filename: String
val caption: String?
val formattedCaption: CharSequence?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
val fileExtension: String
val bestDescription: String
get() = caption ?: filename

View file

@ -15,11 +15,11 @@ data class TimelineItemFileContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val fileSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemFileContent"

View file

@ -31,7 +31,7 @@ fun aTimelineItemFileContent(
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mediaSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "pdf"

View file

@ -18,11 +18,11 @@ data class TimelineItemImageContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,

View file

@ -14,11 +14,11 @@ data class TimelineItemStickerContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,

View file

@ -16,7 +16,7 @@ data class TimelineItemVideoContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val videoSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float?,
val blurHash: String?,
@ -24,9 +24,9 @@ data class TimelineItemVideoContent(
val width: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
override val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"

View file

@ -35,7 +35,7 @@ fun aTimelineItemVideoContent(
blurHash = blurhash,
aspectRatio = aspectRatio,
duration = 100.milliseconds,
videoSource = MediaSource(""),
mediaSource = MediaSource(""),
width = 150,
height = 300,
thumbnailWidth = 150,

View file

@ -19,8 +19,10 @@ data class TimelineItemVoiceContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
override val mediaSource: MediaSource,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val waveform: ImmutableList<Float>,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemAudioContent"

View file

@ -53,4 +53,6 @@ fun aTimelineItemVoiceContent(
mediaSource = mediaSource,
mimeType = mimeType,
waveform = waveform.toPersistentList(),
formattedFileSize = "1.0 MB",
fileExtension = "ogg",
)

View file

@ -31,6 +31,7 @@
<string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string>
<string name="screen_room_timeline_beginning_of_room">"Dies ist der Anfang von %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Dies ist der Anfang dieses Gesprächs."</string>
<string name="screen_room_timeline_legacy_call">"Anruftyp wird nicht unterstützt. Fragen Sie nach, ob der Anrufer die neue Element X-App verwenden kann."</string>
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_timeline_message_copied">"Nachricht wurde kopiert"</string>
<string name="screen_room_timeline_no_permission_to_post">"Du bist nicht berechtigt, in diesem Raum zu schreiben"</string>

View file

@ -31,6 +31,7 @@
<string name="screen_room_timeline_add_reaction">"Ajouter un émoji"</string>
<string name="screen_room_timeline_beginning_of_room">"Ceci est le début de %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Ceci est le début de cette conversation."</string>
<string name="screen_room_timeline_legacy_call">"Appel non pris en charge. Demandez à lappelant sil peut utiliser la nouvelle application Element X pour vous appeler."</string>
<string name="screen_room_timeline_less_reactions">"Afficher moins"</string>
<string name="screen_room_timeline_message_copied">"Message copié"</string>
<string name="screen_room_timeline_no_permission_to_post">"Vous nêtes pas autorisé à publier dans ce salon"</string>

View file

@ -14,7 +14,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
@ -45,6 +45,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ -242,7 +244,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyText, event))
skipItems(2)
assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body)
}
@ -369,7 +371,7 @@ class MessagesPresenterTest {
formattedCaption = null,
isEdited = false,
duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL),
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4,
blurHash = null,
@ -411,7 +413,7 @@ class MessagesPresenterTest {
caption = null,
isEdited = false,
formattedCaption = null,
fileSource = MediaSource(AN_AVATAR_URL),
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
formattedFileSize = "10 MB",
mimeType = MimeTypes.Pdf,
@ -1007,6 +1009,37 @@ class MessagesPresenterTest {
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = A_CAPTION,
showCaptionCompatibilityWarning = true,
)
)
)
}
}
@Test
fun `present - handle action edit caption without warning`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = A_CAPTION,
)
)
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvents.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = A_CAPTION,
showCaptionCompatibilityWarning = false,
)
)
)
@ -1033,6 +1066,37 @@ class MessagesPresenterTest {
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "",
showCaptionCompatibilityWarning = true,
)
)
)
}
}
@Test
fun `present - handle action add caption without warning`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
)
)
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = null,
)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvents.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "",
showCaptionCompatibilityWarning = false,
)
)
)
@ -1097,6 +1161,7 @@ class MessagesPresenterTest {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
timelineEventSink: (TimelineEvents) -> Unit = {},
@ -1109,14 +1174,13 @@ class MessagesPresenterTest {
},
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
val featureFlagService = FakeFeatureFlagService()
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = { aVoiceMessageComposerState() },
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenterFactory = FakeActionListPresenter.Factory(actionListEventSink),
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
customReactionPresenter = { aCustomReactionState() },
reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },

View file

@ -26,6 +26,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -174,8 +176,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@ -219,8 +221,8 @@ class ActionListPresenterTest {
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@ -266,8 +268,8 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@ -312,8 +314,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@ -359,8 +361,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@ -406,10 +408,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -451,10 +453,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -499,10 +501,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
)
)
@ -545,7 +547,58 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.AddCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a media item - caption disabled`() = runTest {
val presenter = createActionListPresenter(
isDeveloperModeEnabled = true,
isPinFeatureEnabled = true,
allowCaption = false,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
// Not here
// TimelineItemAction.AddCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@ -593,10 +646,11 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.EditCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -607,6 +661,54 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for a media with caption item - other user event`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
content = aTimelineItemImageContent(
caption = A_CAPTION,
),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a state item in debug build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@ -711,10 +813,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
)
@ -758,9 +860,9 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -812,10 +914,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Unpin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -908,7 +1010,7 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
)
@ -947,11 +1049,11 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Edit,
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
)
@ -990,8 +1092,8 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
@ -1052,7 +1154,9 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemVoiceContent(),
content = aTimelineItemVoiceContent(
caption = null,
),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@ -1151,6 +1255,7 @@ private fun createActionListPresenter(
isDeveloperModeEnabled: Boolean,
isPinFeatureEnabled: Boolean,
room: MatrixRoom = FakeMatrixRoom(),
allowCaption: Boolean = true,
): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return DefaultActionListPresenter(
@ -1158,6 +1263,11 @@ private fun createActionListPresenter(
appPreferencesStore = preferencesStore,
isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
room = room,
userSendFailureFactory = VerifiedUserSendFailureFactory(room)
userSendFailureFactory = VerifiedUserSendFailureFactory(room),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.MediaCaptionCreation.key to allowCaption,
),
)
)
}

View file

@ -1,24 +0,0 @@
/*
* 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.messages.impl.actionlist
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
class FakeActionListPresenter(private val eventSink: (ActionListEvents) -> Unit = {}) : ActionListPresenter {
class Factory(private val eventSink: (ActionListEvents) -> Unit = {}) : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter {
return FakeActionListPresenter(eventSink)
}
}
@Composable
override fun present(): ActionListState {
return anActionListState(eventSink = eventSink)
}
}

View file

@ -20,6 +20,8 @@ import io.element.android.features.messages.impl.attachments.preview.OnDoneListe
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
@ -40,10 +42,12 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@ -60,6 +64,40 @@ class AttachmentsPreviewPresenterTest {
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
fun `present - initial state`() = runTest {
createAttachmentsPreviewPresenter().test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
assertThat(initialState.allowCaption).isTrue()
assertThat(initialState.showCaptionCompatibilityWarning).isTrue()
}
}
@Test
fun `present - initial state no caption warning`() = runTest {
createAttachmentsPreviewPresenter(
showCaptionCompatibilityWarning = false,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showCaptionCompatibilityWarning).isFalse()
}
}
@Test
fun `present - initial state - caption not allowed`() = runTest {
createAttachmentsPreviewPresenter(
allowCaption = false,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
assertThat(initialState.allowCaption).isFalse()
}
}
@Test
fun `present - send media success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
@ -84,16 +122,142 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
processLatch.complete(Unit)
advanceUntilIdle()
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media with pre-processing failure after user sends media`() = runTest {
val room = FakeMatrixRoom()
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception()))
},
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
}
}
@Test
fun `present - send media with pre-processing failure before user sends media`() = runTest {
val room = FakeMatrixRoom()
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception()))
},
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
processLatch.complete(Unit)
advanceUntilIdle()
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
}
}
@Test
fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder<Unit> { }
@ -108,6 +272,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
@ -138,8 +304,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with(
any(),
any(),
@ -177,8 +345,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with(
any(),
any(),
@ -214,8 +384,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendAudioResult.assertions().isCalledOnce().with(
any(),
any(),
@ -243,8 +415,9 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
sendFileResult.assertions().isCalledOnce()
@ -263,6 +436,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
initialState.eventSink(AttachmentsPreviewEvents.ClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
@ -277,7 +452,10 @@ class AttachmentsPreviewPresenterTest {
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
onDoneListener: OnDoneListener = OnDoneListener {},
onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
mediaUploadOnSendQueueEnabled: Boolean = true,
allowCaption: Boolean = true,
showCaptionCompatibilityWarning: Boolean = true,
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
@ -285,6 +463,13 @@ class AttachmentsPreviewPresenterTest {
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
FeatureFlags.MediaCaptionCreation.key to allowCaption,
FeatureFlags.MediaCaptionWarning.key to showCaptionCompatibilityWarning,
),
)
)
}
}

View file

@ -35,7 +35,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope

View file

@ -1586,7 +1586,12 @@ fun anEditMode(
fun anEditCaptionMode(
eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
caption: String = A_CAPTION,
) = MessageComposerMode.EditCaption(eventOrTransactionId, caption)
showCaptionCompatibilityWarning: Boolean = false,
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = caption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
)
fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),

View file

@ -9,7 +9,7 @@ package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
@ -312,7 +312,7 @@ class PinnedMessagesListPresenterTest {
timelineProvider = timelineProvider,
timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory(),
actionListPresenter = { anActionListState() },
analyticsService = analyticsService,
appCoroutineScope = this,
)

View file

@ -0,0 +1,58 @@
/*
* 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.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import org.junit.Test
class PinnedMessagesListTimelineActionPostProcessorTest {
@Test
fun `ensure that ViewInTimeline is added`() {
val sut = PinnedMessagesListTimelineActionPostProcessor()
val result = sut.process(
listOf()
)
assertThat(result).isEqualTo(
listOf(TimelineItemAction.ViewInTimeline)
)
}
@Test
fun `ensure that some actions are kept and some other are filtered out`() {
val sut = PinnedMessagesListTimelineActionPostProcessor()
val result = sut.process(
listOf(
TimelineItemAction.Forward,
TimelineItemAction.CopyText,
TimelineItemAction.CopyCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Edit,
TimelineItemAction.EditCaption,
TimelineItemAction.AddCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
)
)
assertThat(result).isEqualTo(
listOf(
TimelineItemAction.ViewInTimeline,
TimelineItemAction.Unpin,
TimelineItemAction.Forward,
TimelineItemAction.ViewSource,
)
)
}
}

View file

@ -64,7 +64,7 @@ import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
@ -239,7 +239,7 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
aspectRatio = null,
blurHash = null,
@ -291,7 +291,7 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = SpannedString("formatted"),
isEdited = true,
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
aspectRatio = 3f,
blurHash = A_BLUR_HASH,
@ -380,7 +380,9 @@ class TimelineItemContentMessageFactoryTest {
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
waveform = emptyList<Float>().toImmutableList()
waveform = emptyList<Float>().toImmutableList(),
fileExtension = "",
formattedFileSize = "0 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@ -419,7 +421,9 @@ class TimelineItemContentMessageFactoryTest {
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg,
waveform = persistentListOf(1f, 2f)
waveform = persistentListOf(1f, 2f),
fileExtension = "ogg",
formattedFileSize = "123 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@ -571,7 +575,7 @@ class TimelineItemContentMessageFactoryTest {
caption = null,
formattedCaption = null,
isEdited = false,
fileSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
fileExtension = "",
@ -612,7 +616,7 @@ class TimelineItemContentMessageFactoryTest {
caption = null,
formattedCaption = null,
isEdited = true,
fileSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "123 Bytes",
fileExtension = "pdf",

View file

@ -8,6 +8,8 @@
<string name="screen_advanced_settings_element_call_base_url">"Benutzerdefinierte Element-Aufruf-Basis-URL"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Lege eine eigene Basis-URL für Element Call fest."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Ungültige URL, bitte stelle sicher, dass du das Protokoll (http/https) und die richtige Adresse angibst."</string>
<string name="screen_advanced_settings_media_compression_description">"Laden Sie Fotos und Videos schneller hoch und reduzieren Sie die Datennutzung"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimieren Sie die Medienqualität"</string>
<string name="screen_advanced_settings_push_provider_android">"Anbieter für Push-Benachrichtigungen"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben."</string>
<string name="screen_advanced_settings_send_read_receipts">"Lesebestätigungen"</string>

View file

@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -33,20 +34,16 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
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.overlay.operation.show
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.parcelize.Parcelize
@ -61,6 +58,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -211,22 +209,18 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode<RoomMemberDetailsNode>(buildContext, plugins)
}
is NavTarget.AvatarPreview -> {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
val input = MediaViewerNode.Inputs(
mediaInfo = MediaInfo(
filename = navTarget.name,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = ""
),
mediaSource = MediaSource(url = navTarget.avatarUrl),
thumbnailSource = null,
canDownload = false,
canShare = false,
)
createNode<AvatarPreviewNode>(buildContext, listOf(input))
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
navTarget.name,
navTarget.avatarUrl,
)
.callback(callback)
.build()
}
is NavTarget.PollHistory -> {

View file

@ -7,8 +7,10 @@
<string name="banner_set_up_recovery_content">"Erstelle einen neuen Wiederherstellungsschlüssel, mit dem du deinen verschlüsselten Nachrichtenverlauf wiederherstellen kannst, wenn du dich an einem neuen Gerät anmeldest."</string>
<string name="banner_set_up_recovery_submit">"Wiederherstellung einrichten"</string>
<string name="banner_set_up_recovery_title">"Wiederherstellung einrichten"</string>
<string name="confirm_recovery_key_banner_message">"Dein Chat-Backup ist derzeit nicht synchronisiert. Du musst deinen Wiederherstellungsschlüssel bestätigen, um Zugriff auf dein Chat-Backup zu erhalten."</string>
<string name="confirm_recovery_key_banner_title">"Wiederherstellungsschlüssel bestätigen."</string>
<string name="confirm_recovery_key_banner_message">"Bestätigen Sie die Validität Ihres Wiederherstellungsschlüssels, um weiterhin auf Ihren Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Geben Sie Ihren Wiederherstellungsschlüssel ein"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Haben Sie Ihren Wiederherstellungsschlüssel vergessen?"</string>
<string name="confirm_recovery_key_banner_title">"Ihr Schlüsselspeicher ist nicht synchronisiert"</string>
<string name="full_screen_intent_banner_message">"Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst."</string>
<string name="full_screen_intent_banner_title">"Verbessere dein Anruferlebnis"</string>
<string name="screen_invites_decline_chat_message">"Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"</string>
@ -17,6 +19,7 @@
<string name="screen_invites_decline_direct_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
<string name="screen_join_room_knock_sent_title">"Beitrittsanfrage geschickt"</string>
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto wird eingerichtet."</string>
<string name="screen_roomlist_a11y_create_message">"Eine Unterthaltung oder Raum erstellen"</string>

View file

@ -28,6 +28,7 @@ import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ -111,10 +112,10 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {
if (callbacks.isNotEmpty()) {
callbacks.forEach { it.onDone() }
} else {
if (backstack.canPop()) {
backstack.pop()
} else {
callbacks.forEach { it.onDone() }
}
}
}

View file

@ -2,11 +2,15 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Backup deaktivieren"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Backup aktivieren"</string>
<string name="screen_chat_backup_key_backup_description">"Das Backup stellt sicher, dass du deinen Nachrichtenverlauf nicht verlierst. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_key_backup_description">"Speichern Sie Ihre verschlüsselte Identität und Ihre codierten Nachrichtenschlüssel auf dem Server. Auf diese Weise können Sie Ihren Nachrichtenverlauf auf allen neuen Geräten einsehen. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Schlüsselspeicher"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Der Schlüsselspeicher muss aktiviert sein, um Datenwiederherstellung zu ermöglichen."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Schlüssel von diesem Gerät hochladen"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Schlüsselspeicherung zulassen"</string>
<string name="screen_chat_backup_recovery_action_change">"Wiederherstellungsschlüssel ändern"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Stellen Sie Ihre verschlüsselte Identität und Ihren Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls Sie den Zugang zu allen Ihren Geräten verloren haben."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Wiederherstellungsschlüssel eingeben"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Chat-Backup ist derzeit nicht synchronisiert."</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Schlüssel ist derzeit nicht synchronisiert."</string>
<string name="screen_chat_backup_recovery_action_setup">"Wiederherstellung einrichten"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verlierst oder von %1$s überall abgemeldet bist."</string>
<string name="screen_create_new_recovery_key_list_item_1">
@ -33,6 +37,8 @@
<string name="screen_encryption_reset_bullet_1">"Deine Kontodaten, Kontakte, Einstellungen und die Liste der Chats bleiben erhalten"</string>
<string name="screen_encryption_reset_bullet_2">"Du verlierst alle deine bisherigen Nachrichten sofern sie nicht auf einem anderen Gerät vorliegen"</string>
<string name="screen_encryption_reset_bullet_3">"Du musst alle deine bestehenden Geräte und Kontakte erneut verifizieren."</string>
<string name="screen_encryption_reset_footer">"Setzen Sie Ihre Identität nur dann zurück, wenn Sie keinen Zugriff auf ein anderes Ihrer angemeldeten Geräte und auch Ihren Wiederherstellungsschlüssel verloren haben."</string>
<string name="screen_encryption_reset_title">"Sie können es nicht bestätigen? Dann müssen Sie Ihre Identität zurücksetzen."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Ausschalten"</string>
<string name="screen_key_backup_disable_confirmation_description">"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."</string>
<string name="screen_key_backup_disable_confirmation_title">"Bist du sicher, dass du das Backup deaktivieren willst?"</string>
@ -42,7 +48,7 @@
<string name="screen_key_backup_disable_title">"Bist du sicher, dass du das Backup deaktivieren willst?"</string>
<string name="screen_recovery_key_change_description">"Hier kannst Du einen neuen Wiederherstellungsschlüssel erstellen. Nachdem Du einen neuen Wiederherstellungsschlüssel erstellt hast, funktioniert dein alter Schlüssel nicht mehr."</string>
<string name="screen_recovery_key_change_generate_key">"Wiederherstellungsschlüssel erstellen"</string>
<string name="screen_recovery_key_change_generate_key_description">"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"</string>
<string name="screen_recovery_key_change_generate_key_description">"Geben Sie dies an niemanden weiter!"</string>
<string name="screen_recovery_key_change_success">"Wiederherstellungsschlüssel geändert"</string>
<string name="screen_recovery_key_change_title">"Wiederherstellungsschlüssel ändern?"</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">
@ -51,23 +57,24 @@
" erstellen"
</string>
<string name="screen_recovery_key_confirm_description">"Sorge dafür, dass niemand diesen Bildschirm sehen kann!"</string>
<string name="screen_recovery_key_confirm_error_content">"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
<string name="screen_recovery_key_confirm_error_content">"Bitte versuchen Sie erneut, den Zugriff auf Ihren Schlüsselspeicher zu bestätigen."</string>
<string name="screen_recovery_key_confirm_error_title">"Falscher Wiederherstellungsschlüssel"</string>
<string name="screen_recovery_key_confirm_key_description">"Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Eingeben…"</string>
<string name="screen_recovery_key_confirm_lost_recovery_key">"Hast du deinen Wiederherstellungschlüssel vergessen?"</string>
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</string>
<string name="screen_recovery_key_confirm_title">"Geben Sie Ihren Wiederherstellungsschlüssel ein"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Wiederherstellungsschlüssel kopiert"</string>
<string name="screen_recovery_key_generating_key">"Generieren…"</string>
<string name="screen_recovery_key_save_action">"Wiederherstellungsschlüssel speichern"</string>
<string name="screen_recovery_key_save_description">"Notiere dir deinen Wiederherstellungsschlüssel an einem sicheren Ort oder speichere ihn in einem Passwort-Manager."</string>
<string name="screen_recovery_key_save_description">"Schreiben Sie Ihren Wiederherstellungsschlüssel in eine verschlüsselte Datei, oder in einem Passwort-Manager oder in einem Safe. "</string>
<string name="screen_recovery_key_save_key_description">"Tippe, um den Wiederherstellungsschlüssel zu kopieren"</string>
<string name="screen_recovery_key_save_title">"Speichere deinen Wiederherstellungsschlüssel"</string>
<string name="screen_recovery_key_setup_confirmation_description">"Nach diesem Schritt kannst du nicht mehr auf deinen neuen Wiederherstellungsschlüssel zugreifen."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Hast du deinen Wiederherstellungsschlüssel gespeichert?"</string>
<string name="screen_recovery_key_setup_description">"Dein Chat-Backup ist durch einen Wiederherstellungsschlüssel geschützt. Wenn du nach der Einrichtung einen neuen Wiederherstellungsschlüssel brauchst, kannst du ihn über die Option \"Wiederherstellungsschlüssel ändern\" neu erstellen."</string>
<string name="screen_recovery_key_setup_generate_key">"Wiederherstellungsschlüssel erstellen"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Geben Sie dies an niemanden weiter!"</string>
<string name="screen_recovery_key_setup_success">"Einrichtung der Wiederherstellung erfolgreich"</string>
<string name="screen_recovery_key_setup_title">"Wiederherstellung einrichten"</string>
<string name="screen_reset_encryption_confirmation_alert_action">"Ja, zurücksetzen"</string>

View file

@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -24,18 +25,14 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
import io.element.android.libraries.architecture.BackstackView
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.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ -44,6 +41,7 @@ class UserProfileFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -80,22 +78,18 @@ class UserProfileFlowNode @AssistedInject constructor(
createNode<UserProfileNode>(buildContext, listOf(callback, params))
}
is NavTarget.AvatarPreview -> {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
val input = MediaViewerNode.Inputs(
mediaInfo = MediaInfo(
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
filename = navTarget.name,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
),
mediaSource = MediaSource(url = navTarget.avatarUrl),
thumbnailSource = null,
canDownload = false,
canShare = false,
)
createNode<AvatarPreviewNode>(buildContext, listOf(input))
avatarUrl = navTarget.avatarUrl
)
.callback(callback)
.build()
}
}
}

View file

@ -1,24 +0,0 @@
/*
* 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.userprofile.shared.avatar
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
@ContributesNode(SessionScope::class)
class AvatarPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : MediaViewerNode(buildContext, plugins, presenterFactory)

View file

@ -13,5 +13,7 @@
<string name="screen_room_member_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_room_member_details_unblock_alert_description">"Der Nutzer kann dir wieder Nachrichten senden &amp; alle Nachrichten des Nutzers werden wieder angezeigt."</string>
<string name="screen_room_member_details_unblock_user">"Blockierung aufheben"</string>
<string name="screen_room_member_details_verify_button_subtitle">"Verwenden Sie die Web-App, um diesen Benutzer zu verifizieren."</string>
<string name="screen_room_member_details_verify_button_title">"Überprüfen Sie %1$s"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Sie können es nicht bestätigen?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Erstelle einen neuen Wiederherstellungsschlüssel"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."</string>
<string name="screen_identity_confirmation_title">"Bestätige, dass du es bist"</string>
@ -16,6 +17,7 @@
<string name="screen_session_verification_compare_numbers_title">"Vergleiche die Zahlen"</string>
<string name="screen_session_verification_complete_subtitle">"Deine neue Session ist nun verifiziert. Sie hat Zugriff auf deine verschlüsselten Nachrichten und wird von anderen Benutzern als vertrauenswürdig eingestuft."</string>
<string name="screen_session_verification_enter_recovery_key">"Wiederherstellungsschlüssel eingeben"</string>
<string name="screen_session_verification_failed_subtitle">"Entweder ist bei der Anfrage ein Timeout aufgetreten, oder die Anfrage wurde abgelehnt, oder es gab eine Nichtübereinstimmung bei der Überprüfung."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Beweise deine Identität, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen."</string>
<string name="screen_session_verification_open_existing_session_title">"Öffne eine bestehende Session"</string>
<string name="screen_session_verification_positive_button_canceled">"Verifizierung wiederholen"</string>
@ -23,8 +25,20 @@
<string name="screen_session_verification_positive_button_verifying_ongoing">"Warten auf eine Übereinstimmung"</string>
<string name="screen_session_verification_ready_subtitle">"Vergleiche eine spezielle Reihe von Emojis."</string>
<string name="screen_session_verification_request_accepted_subtitle">"Vergleiche die einzelnen Emojis und stelle sicher, dass sie in der gleichen Reihenfolge erscheinen."</string>
<string name="screen_session_verification_request_details_timestamp">"Angemeldet"</string>
<string name="screen_session_verification_request_failure_subtitle">"Entweder ist bei der Anfrage ein Timeout aufgetreten, oder die Anfrage wurde abgelehnt, oder es gab eine Nichtübereinstimmung bei der Überprüfung."</string>
<string name="screen_session_verification_request_failure_title">"Überprüfung fehlgeschlagen"</string>
<string name="screen_session_verification_request_footer">"Fahren Sie nur fort, falls Sie für diese Überprüfung verantwortlich sind.."</string>
<string name="screen_session_verification_request_subtitle">"Verifizieren Sie das andere Gerät, um die Sicherheit Ihres Nachrichtenverlaufs zu gewährleisten."</string>
<string name="screen_session_verification_request_success_subtitle">"Jetzt können Sie gesichert Nachrichten auf Ihrem anderen Gerät lesen oder senden."</string>
<string name="screen_session_verification_request_success_title">"Gerät verifiziert"</string>
<string name="screen_session_verification_request_title">"Verifizierung angefordert"</string>
<string name="screen_session_verification_they_dont_match">"Sie stimmen nicht überein"</string>
<string name="screen_session_verification_they_match">"Sie stimmen überein"</string>
<string name="screen_session_verification_use_another_device_subtitle">"Stellen Sie sicher, dass die App auf dem anderen Gerät geöffnet ist, bevor Sie die Überprüfung auf diesem Gerät aus starten."</string>
<string name="screen_session_verification_use_another_device_title">"Öffnen Sie die App auf einem anderen verifizierten Gerät"</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"Sie sollten ein Popup-Fenster auf dem anderen Gerät sehen. Starten Sie die Überprüfung von dort aus."</string>
<string name="screen_session_verification_waiting_another_device_title">"Starten Sie die Überprüfung auf dem anderen Gerät"</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Akzeptiere die Anfrage, um den Verifizierungsprozess in deiner anderen Session zu starten, um fortzufahren."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Warten auf die Annahme der Anfrage"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string>

View file

@ -17,6 +17,7 @@
<string name="screen_session_verification_compare_numbers_title">"Porównaj liczby"</string>
<string name="screen_session_verification_complete_subtitle">"Twoja nowa sesja jest teraz zweryfikowana. Ma ona dostęp do Twoich zaszyfrowanych wiadomości, a inni użytkownicy będą widzieć ją jako zaufaną."</string>
<string name="screen_session_verification_enter_recovery_key">"Wprowadź klucz przywracania"</string>
<string name="screen_session_verification_failed_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."</string>
<string name="screen_session_verification_open_existing_session_title">"Otwórz istniejącą sesję"</string>
<string name="screen_session_verification_positive_button_canceled">"Ponów weryfikację"</string>
@ -25,9 +26,11 @@
<string name="screen_session_verification_ready_subtitle">"Porównaj unikalny zestaw emoji."</string>
<string name="screen_session_verification_request_accepted_subtitle">"Porównaj unikalne emoji, upewniając się, że pojawiły się w tej samej kolejności."</string>
<string name="screen_session_verification_request_details_timestamp">"Zalogowano"</string>
<string name="screen_session_verification_request_failure_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_request_failure_title">"Weryfikacja nie powiodła się"</string>
<string name="screen_session_verification_request_footer">"Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację."</string>
<string name="screen_session_verification_request_subtitle">"Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości."</string>
<string name="screen_session_verification_request_success_subtitle">"Już możesz bezpiecznie czytać lub wysyłać wiadomości na drugim urządzeniu."</string>
<string name="screen_session_verification_request_success_title">"Urządzenie zweryfikowane"</string>
<string name="screen_session_verification_request_title">"Zażądano weryfikacji"</string>
<string name="screen_session_verification_they_dont_match">"Nie pasują do siebie"</string>

View file

@ -21,7 +21,7 @@ constraintlayout = "2.2.0"
constraintlayout_compose = "1.1.0"
lifecycle = "2.8.7"
activity = "1.9.3"
media3 = "1.4.1"
media3 = "1.5.0"
camera = "1.4.0"
# Compose
@ -46,14 +46,14 @@ coil = "2.7.0"
showkase = "1.0.3"
appyx = "1.5.1"
sqldelight = "2.0.2"
wysiwyg = "2.37.13"
wysiwyg = "2.37.14"
telephoto = "0.14.0"
# Dependency analysis
dependencyAnalysis = "2.5.0"
# DI
dagger = "2.52"
dagger = "2.53"
anvil = "0.4.0"
# Auto service
@ -150,7 +150,7 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.13"
test_konsist = "com.lemonappdev:konsist:0.16.1"
test_konsist = "com.lemonappdev:konsist:0.17.1"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.18"
@ -173,7 +173,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.65"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.68"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -201,7 +201,7 @@ matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3"
sigpwned_emoji4j = "com.sigpwned:emoji4j-core:15.1.2"
sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
# Di
inject = "javax.inject:javax.inject:1"
@ -233,7 +233,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.7"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.2"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:11.1.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
@ -241,6 +241,6 @@ paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
sonarqube = "org.sonarqube:6.0.0.5145"
sonarqube = "org.sonarqube:6.0.1.5171"
licensee = "app.cash.licensee:1.12.0"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -11,4 +11,9 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.api"
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.libraries.dateformatter.api
import java.util.Locale
/**
* Convert milliseconds to human readable duration.
* Hours in 1 digit or more.
* Minutes in 2 digits when hours are available.
* Seconds always on 2 digits.
* Example:
* - when the duration is longer than 1 hour:
* - "10:23:34"
* - "1:23:34"
* - "1:03:04"
* - when the duration is shorter:
* - "4:56"
* - "14:06"
* - Less than one minute:
* - "0:00"
* - "0:01"
* - "0:59"
*/
fun Long.toHumanReadableDuration(): String {
val inSeconds = this / 1_000
val hours = inSeconds / 3_600
val minutes = inSeconds % 3_600 / 60
val seconds = inSeconds % 60
return if (hours > 0) {
String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.US, "%d:%02d", minutes, seconds)
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.libraries.dateformatter.api
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class DurationFormatterTest {
@Test
fun `format seconds only`() {
assertThat(buildDuration().toHumanReadableDuration()).isEqualTo("0:00")
assertThat(buildDuration(seconds = 1).toHumanReadableDuration()).isEqualTo("0:01")
assertThat(buildDuration(seconds = 59).toHumanReadableDuration()).isEqualTo("0:59")
}
@Test
fun `format minutes and seconds`() {
assertThat(buildDuration(minutes = 1).toHumanReadableDuration()).isEqualTo("1:00")
assertThat(buildDuration(minutes = 1, seconds = 30).toHumanReadableDuration()).isEqualTo("1:30")
assertThat(buildDuration(minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("59:59")
}
@Test
fun `format hours, minutes and seconds`() {
assertThat(buildDuration(hours = 1).toHumanReadableDuration()).isEqualTo("1:00:00")
assertThat(buildDuration(hours = 1, minutes = 1, seconds = 1).toHumanReadableDuration()).isEqualTo("1:01:01")
assertThat(buildDuration(hours = 24, minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("24:59:59")
assertThat(buildDuration(hours = 25, minutes = 0, seconds = 0).toHumanReadableDuration()).isEqualTo("25:00:00")
}
private fun buildDuration(
hours: Int = 0,
minutes: Int = 0,
seconds: Int = 0
): Long {
return (hours * 60 * 60 + minutes * 60 + seconds) * 1000L
}
}

View file

@ -5,19 +5,32 @@
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -32,8 +45,20 @@ fun Slider(
steps: Int = 0,
onValueChangeFinish: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
useCustomLayout: Boolean = false,
) {
val thumbColor = ElementTheme.colors.iconOnSolidPrimary
var isUserInteracting by remember { mutableStateOf(false) }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
isUserInteracting = when (interaction) {
is DragInteraction.Start,
is PressInteraction.Press -> true
else -> false
}
}
}
androidx.compose.material3.Slider(
value = value,
onValueChange = onValueChange,
@ -43,6 +68,54 @@ fun Slider(
steps = steps,
onValueChangeFinished = onValueChangeFinish,
colors = colors,
thumb = {
if (useCustomLayout) {
SliderDefaults.Thumb(
modifier = Modifier.drawWithContent {
drawContent()
if (isUserInteracting.not()) {
drawCircle(thumbColor, radius = 8.dp.toPx())
}
},
interactionSource = interactionSource,
colors = colors.copy(
thumbColor = ElementTheme.colors.iconPrimary,
),
enabled = enabled,
thumbSize = DpSize(
if (isUserInteracting) 44.dp else 22.dp,
22.dp,
),
)
} else {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = colors,
enabled = enabled
)
}
},
track = { sliderState ->
if (useCustomLayout) {
SliderDefaults.Track(
modifier = Modifier.height(8.dp),
colors = colors.copy(
activeTrackColor = Color(0x66E0EDFF),
inactiveTrackColor = Color(0x66E0EDFF),
),
enabled = enabled,
sliderState = sliderState,
thumbTrackGapSize = 0.dp,
drawStopIndicator = { },
)
} else {
SliderDefaults.Track(
colors = colors,
enabled = enabled,
sliderState = sliderState,
)
}
},
interactionSource = interactionSource,
)
}
@ -55,5 +128,6 @@ internal fun SlidersPreview() = ElementThemedPreview {
Slider(onValueChange = { value = it }, value = value, enabled = true)
Slider(steps = 10, onValueChange = { value = it }, value = value, enabled = true)
Slider(onValueChange = { value = it }, value = value, enabled = false)
Slider(onValueChange = { value = it }, value = value, enabled = true, useCustomLayout = true)
}
}

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.designsystem.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@Composable
fun HideKeyboardWhenDisposed() {
val keyboardController = LocalSoftwareKeyboardController.current
DisposableEffect(Unit) {
onDispose {
keyboardController?.hide()
}
}
}

View file

@ -28,7 +28,7 @@
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s hat den Raum betreten"</string>
<string name="state_event_room_join_by_you">"Du hast den Raum betreten"</string>
<string name="state_event_room_knock">"%1$s hat angefragt beizutreten"</string>
<string name="state_event_room_knock">"%1$s beantragt den Beitritt"</string>
<string name="state_event_room_knock_accepted">"%1$s hat %2$s den Beitritt erlaubt"</string>
<string name="state_event_room_knock_accepted_by_you">"Du hast %1$s den Beitritt erlaubt."</string>
<string name="state_event_room_knock_by_you">"Du hast angefragt beizutreten"</string>

View file

@ -140,4 +140,18 @@ enum class FeatureFlags(
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
MediaCaptionCreation(
key = "feature.media_caption_creation",
title = "Allow creation of media captions",
description = null,
defaultValue = { true },
isFinished = false,
),
MediaCaptionWarning(
key = "feature.media_caption_creation_warning",
title = "Show a compatibility warning on media captions creation",
description = null,
defaultValue = { true },
isFinished = false,
),
}

View file

@ -12,5 +12,22 @@ enum class UtdCause {
SentBeforeWeJoined,
VerificationViolation,
UnsignedDevice,
UnknownDevice
UnknownDevice,
/**
* Expected utd because this is a device-historical message and
* key storage is not setup or not configured correctly.
*/
HistoricalMessage,
/**
* The key was withheld on purpose because your device is insecure and/or the
* sender trust requirement settings are not met for your device.
*/
WithheldUnverifiedOrInsecureDevice,
/**
* Key is withheld by sender.
*/
WithheldBySender,
}

View file

@ -27,6 +27,7 @@ class UtdTracker(
UtdCause.UNKNOWN_DEVICE -> {
Error.Name.ExpectedSentByInsecureDevice
}
UtdCause.HISTORICAL_MESSAGE -> Error.Name.HistoricalMessage
}
val event = Error(
context = null,

View file

@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
import org.matrix.rustcomponents.sdk.use
import java.io.File
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
@ -86,7 +85,7 @@ class RustMediaLoader(
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
mediaSourceFromUrl(url)
RustMediaSource.fromUrl(url)
}
}
}

View file

@ -141,7 +141,7 @@ class RustRoomFactory(
}
val innerRoom = try {
roomListItem.previewRoom(via = emptyList())
} catch (e: RoomListException) {
} catch (e: Exception) {
Timber.e(e, "Failed to get pending room for $roomId")
return@withContext null
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room.preview
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
@ -25,7 +26,7 @@ object RoomPreviewInfoMapper {
avatarUrl = info.avatarUrl,
numberOfJoinedMembers = info.numJoinedMembers.toLong(),
roomType = info.roomType.map(),
isHistoryWorldReadable = info.isHistoryWorldReadable,
isHistoryWorldReadable = info.isHistoryWorldReadable.orFalse(),
isJoined = info.membership == Membership.JOINED,
isInvited = info.membership == Membership.INVITED,
isPublic = info.joinRule == JoinRule.Public,

View file

@ -145,6 +145,7 @@ private fun RustUtdCause.map(): UtdCause {
RustUtdCause.VERIFICATION_VIOLATION -> UtdCause.VerificationViolation
RustUtdCause.UNSIGNED_DEVICE -> UtdCause.UnsignedDevice
RustUtdCause.UNKNOWN_DEVICE -> UtdCause.UnknownDevice
RustUtdCause.HISTORICAL_MESSAGE -> UtdCause.HistoricalMessage
}
}

View file

@ -27,6 +27,35 @@ class MediaSender @Inject constructor(
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
): Result<MediaUploadInfo> {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
compressIfPossible = compressIfPossible,
)
}
suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<Unit> {
return room.sendMedia(
uploadInfo = mediaUploadInfo,
progressCallback = progressCallback,
caption = caption,
formattedCaption = formattedCaption
)
.handleSendResult()
}
suspend fun sendMedia(
uri: Uri,
mimeType: String,

View file

@ -22,3 +22,11 @@ sealed interface MediaUploadInfo {
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Float>) : MediaUploadInfo
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
}
fun MediaUploadInfo.allFiles(): List<File> {
return listOfNotNull(
file,
(this@allFiles as? MediaUploadInfo.Image)?.thumbnailFile,
(this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile,
)
}

View file

@ -16,10 +16,13 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import java.io.File
import kotlin.time.Duration.Companion.seconds
class FakeMediaPreProcessor : MediaPreProcessor {
class FakeMediaPreProcessor(
private val processLatch: CompletableDeferred<Unit>? = null,
) : MediaPreProcessor {
var processCallCount = 0
private set
@ -41,6 +44,7 @@ class FakeMediaPreProcessor : MediaPreProcessor {
deleteOriginal: Boolean,
compressIfPossible: Boolean
): Result<MediaUploadInfo> = simulateLongTask {
processLatch?.await()
processCallCount++
result
}

View file

@ -13,44 +13,12 @@ plugins {
android {
namespace = "io.element.android.libraries.mediaviewer.api"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
dependencies {
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.coroutines.core)
implementation(libs.dagger)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.flick)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -1,11 +1,11 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.libraries.mediaviewer.api.local
package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes
@ -18,44 +18,73 @@ data class MediaInfo(
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
val senderName: String?,
val dateSent: String?,
) : Parcelable
fun anImageMediaInfo(): MediaInfo = MediaInfo(
fun anImageMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an image file.jpg",
caption = null,
caption = caption,
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
senderName = senderName,
dateSent = dateSent,
)
fun aVideoMediaInfo(): MediaInfo = MediaInfo(
fun aVideoMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "a video file.mp4",
caption = null,
caption = caption,
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
senderName = senderName,
dateSent = dateSent,
)
fun aPdfMediaInfo(): MediaInfo = MediaInfo(
fun aPdfMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "a pdf file.pdf",
caption = null,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
senderName = senderName,
dateSent = dateSent,
)
fun anApkMediaInfo(): MediaInfo = MediaInfo(
fun anApkMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an apk file.apk",
caption = null,
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
senderName = senderName,
dateSent = dateSent,
)
fun anAudioMediaInfo(): MediaInfo = MediaInfo(
fun anAudioMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an audio file.mp3",
caption = null,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
senderName = senderName,
dateSent = dateSent,
)

View file

@ -0,0 +1,38 @@
/*
* 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.libraries.mediaviewer.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.media.MediaSource
interface MediaViewerEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun avatar(filename: String, avatarUrl: String): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onDone()
}
data class Params(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.MediaInfo
import kotlinx.parcelize.Parcelize
@Parcelize

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
interface LocalMediaFactory {
/**

View file

@ -0,0 +1,15 @@
/*
* 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.libraries.mediaviewer.api.local
import androidx.compose.runtime.Composable
interface LocalMediaRenderer {
@Composable
fun Render(localMedia: LocalMedia)
}

View file

@ -1,324 +0,0 @@
/*
* Copyright 2023, 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.libraries.mediaviewer.api.local
import android.annotation.SuppressLint
import android.net.Uri
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
// TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier,
onClick = onClick,
)
}
}
@Composable
private fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier,
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier,
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
onClick = { onClick() }
)
}
}
@Composable
private fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Text(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.wrapContentSize(),
text = "A Video Player will render here",
)
} else {
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
onClick = onClick,
modifier = modifier,
)
}
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var playableState: PlayableState.Playable by remember {
mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false))
}
localMediaViewState.playableState = playableState
val context = LocalContext.current
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
playableState = playableState.copy(isPlaying = isPlaying)
}
}
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
.apply {
addListener(playerListener)
this.prepare()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
KeepScreenOn(playableState.isPlaying)
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setOnClickListener {
onClick()
}
setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
val isShowingControls = visibility == View.VISIBLE
playableState = playableState.copy(isShowingControls = isShowingControls)
})
controllerShowTimeoutMs = 1500
setShowPreviousButton(false)
setShowFastForwardButton(false)
setShowRewindButton(false)
setShowNextButton(false)
showController()
}
},
onRelease = { playerView ->
playerView.setOnClickListener(null)
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
playerView.player = null
},
modifier = modifier
)
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun MediaPDFView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = localMediaViewState.zoomableState,
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(
pdfViewerState = pdfViewerState,
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = 8.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isAudio) Icons.Outlined.GraphicEq else CompoundIcons.Attachment(),
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = info.filename,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View file

@ -7,30 +7,6 @@
package io.element.android.libraries.mediaviewer.api.util
import android.webkit.MimeTypeMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface FileExtensionExtractor {
fun extractFromName(name: String): String
}
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}
class FileExtensionExtractorWithoutValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
return name.substringAfterLast('.', "")
}
}

View file

@ -1,90 +0,0 @@
/*
* Copyright 2023, 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.libraries.mediaviewer.api.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
aMediaViewerState(AsyncData.Loading()),
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
anImageMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aVideoMediaInfo())
),
aVideoMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aPdfMediaInfo())
),
aPdfMediaInfo(),
),
aMediaViewerState(
AsyncData.Loading(),
anApkMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anApkMediaInfo())
),
anApkMediaInfo(),
),
aMediaViewerState(
AsyncData.Loading(),
anAudioMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anAudioMediaInfo())
),
anAudioMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
anImageMediaInfo(),
canDownload = false,
canShare = false,
),
)
}
fun aMediaViewerState(
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(),
canDownload: Boolean = true,
canShare: Boolean = true,
eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState(
mediaInfo = mediaInfo,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null,
canDownload = canDownload,
canShare = canShare,
eventSink = eventSink,
)

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