Merge branch 'develop' into feature/fga/requests_to_join_list
This commit is contained in:
commit
d57ec1c2f8
412 changed files with 4675 additions and 2105 deletions
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<string name="screen_app_lock_biometric_authentication">"l’authentification 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
|
|||
return TextDisplay.Custom(mentionSpan)
|
||||
}
|
||||
},
|
||||
isEditor = false,
|
||||
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
|
||||
).apply {
|
||||
configureWith(editorStyle)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ fun aTimelineItemVideoContent(
|
|||
blurHash = blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
duration = 100.milliseconds,
|
||||
videoSource = MediaSource(""),
|
||||
mediaSource = MediaSource(""),
|
||||
width = 150,
|
||||
height = 300,
|
||||
thumbnailWidth = 150,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -53,4 +53,6 @@ fun aTimelineItemVoiceContent(
|
|||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
waveform = waveform.toPersistentList(),
|
||||
formattedFileSize = "1.0 MB",
|
||||
fileExtension = "ogg",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 à l’appelant s’il 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>
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -11,4 +11,9 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.dateformatter.api"
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class UtdTracker(
|
|||
UtdCause.UNKNOWN_DEVICE -> {
|
||||
Error.Name.ExpectedSentByInsecureDevice
|
||||
}
|
||||
UtdCause.HISTORICAL_MESSAGE -> Error.Name.HistoricalMessage
|
||||
}
|
||||
val event = Error(
|
||||
context = null,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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('.', "")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue