[Media upload] Add media pickers to the Room screen and the composer (#380)

* Add media pickers to the Room screen and the composer.

* Fix exclude rules for translations
This commit is contained in:
Jorge Martin Espinosa 2023-05-04 11:51:03 +02:00 committed by GitHub
parent 581c5ab2d2
commit 014c0f4186
40 changed files with 438 additions and 114 deletions

1
changelog.d/360.feature Normal file
View file

@ -0,0 +1 @@
Add media pickers to the room screen.

View file

@ -10,9 +10,9 @@
<string name="screen_create_room_public_option_title">"Public room (anyone)"</string>
<string name="screen_create_room_room_name_label">"Room name"</string>
<string name="screen_create_room_room_name_placeholder">"e.g. Product Sprint"</string>
<string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"What is this room about?"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_unknown_profile">"We cant validate this users Matrix ID. The invite might not be received."</string>
<string name="screen_create_room_title">"Create a room"</string>
</resources>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string>
</resources>

View file

@ -32,6 +32,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

View file

@ -27,7 +27,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor(

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemContent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
@ -32,6 +33,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
get() = sequenceOf(
aMessagesState(),
aMessagesState().copy(hasNetworkConnection = false),
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.AllMedia)),
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.Camera)),
)
}

View file

@ -21,6 +21,7 @@
package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -35,6 +36,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@ -43,11 +45,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -57,20 +62,24 @@ import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import kotlinx.coroutines.launch
import timber.log.Timber
@ -86,9 +95,24 @@ fun MessagesView(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
ModalBottomSheetValue.Expanded
} else {
ModalBottomSheetValue.Hidden
}
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
val coroutineScope = rememberCoroutineScope()
BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch {
bottomSheetState.hide()
}
}
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
LogCompositions(tag = "MessagesScreen", msg = "Content")
fun onMessageClicked(event: TimelineItem.Event) {
@ -97,7 +121,7 @@ fun MessagesView(
fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}")
focusManager.clearFocus(force = true)
localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
coroutineScope.launch {
itemActionsBottomSheetState.show()
@ -108,41 +132,67 @@ fun MessagesView(
state.eventSink(MessagesEvents.HandleAction(action, event))
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
LaunchedEffect(composerState.attachmentSourcePicker) {
if (composerState.attachmentSourcePicker != null) {
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
localView.hideKeyboard()
bottomSheetState.show()
} else {
bottomSheetState.hide()
}
}
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
LaunchedEffect(bottomSheetState.isVisible) {
if (!bottomSheetState.isVisible) {
composerState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
}
}
ModalBottomSheetLayout(
sheetState = bottomSheetState,
displayHandle = true,
sheetContent = {
MediaPickerMenu(
addAttachmentSourcePicker = composerState.attachmentSourcePicker,
eventSink = composerState.eventSink
)
}
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
)
}
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
)
}
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
ActionListView(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = ::onActionSelected
)
ActionListView(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = ::onActionSelected
)
}
}
@Composable
@ -216,6 +266,53 @@ fun MessagesViewTopBar(
)
}
@Composable
internal fun MediaPickerMenu(
addAttachmentSourcePicker: AttachmentSourcePicker?,
eventSink: (MessageComposerEvents) -> Unit,
) {
when (addAttachmentSourcePicker) {
null -> return
AttachmentSourcePicker.AllMedia -> AllMediaSourcePickerMenu(eventSink = eventSink)
AttachmentSourcePicker.Camera -> CameraSourcePickerMenu(eventSink = eventSink)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun AllMediaSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }) {
Text(stringResource(R.string.screen_room_attachment_source_gallery))
}
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }) {
Text(stringResource(R.string.screen_room_attachment_source_files))
}
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera) }) {
Text(stringResource(R.string.screen_room_attachment_source_camera))
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun CameraSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo) }) {
Text(stringResource(R.string.screen_room_attachment_source_camera_photo))
}
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video) }) {
Text(stringResource(R.string.screen_room_attachment_source_camera_video))
}
}
}
@Preview
@Composable
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =

View file

@ -24,6 +24,15 @@ sealed interface MessageComposerEvents {
object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: CharSequence) : MessageComposerEvents
object TakePhoto : MessageComposerEvents
object AddAttachment : MessageComposerEvents
object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {
object FromGallery : PickAttachmentSource
object FromCamera : PickAttachmentSource
object FromFiles : PickAttachmentSource
}
sealed interface PickCameraAttachmentSource : MessageComposerEvents {
object Photo : PickCameraAttachmentSource
object Video : PickCameraAttachmentSource
}
}

View file

@ -19,13 +19,17 @@ package io.element.android.features.messages.impl.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -36,6 +40,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val room: MatrixRoom,
@ -47,11 +52,22 @@ class MessageComposerPresenter @Inject constructor(
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
// Example usage of custom pickers
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
Timber.d("Media picked from $uri")
})
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
Timber.d("File picked from $uri")
})
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
Timber.d("Photo saved at $uri")
})
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
Timber.d("Video saved at $uri")
})
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
@ -62,6 +78,8 @@ class MessageComposerPresenter @Inject constructor(
mutableStateOf(MessageComposerMode.Normal(""))
}
var attachmentSourcePicker: AttachmentSourcePicker? by remember { mutableStateOf(null) }
LaunchedEffect(composerMode.value) {
when (val modeValue = composerMode.value) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
@ -80,21 +98,47 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
MessageComposerEvents.TakePhoto -> localCoroutineScope.launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
cameraPhotoPicker.launch()
}
}}
MessageComposerEvents.AddAttachment -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = AttachmentSourcePicker.AllMedia
}
MessageComposerEvents.DismissAttachmentMenu -> attachmentSourcePicker = null
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = null
galleryMediaPicker.launch()
}
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = null
filesPicker.launch()
}
MessageComposerEvents.PickAttachmentSource.FromCamera -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = AttachmentSourcePicker.Camera
}
MessageComposerEvents.PickCameraAttachmentSource.Photo -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = null
cameraPhotoPicker.launch()
}
MessageComposerEvents.PickCameraAttachmentSource.Video -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = null
cameraVideoPicker.launch()
}
}
}
return MessageComposerState(
text = text.value,
isFullScreen = isFullScreen.value,
mode = composerMode.value,
attachmentSourcePicker = attachmentSourcePicker,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.ifMediaPickersEnabled(action: suspend () -> Unit) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
action()
}
}
private fun MutableState<MessageComposerMode>.setToNormal() {
value = MessageComposerMode.Normal("")
}

View file

@ -25,7 +25,13 @@ data class MessageComposerState(
val text: StableCharSequence?,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val attachmentSourcePicker: AttachmentSourcePicker?,
val eventSink: (MessageComposerEvents) -> Unit
) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
}
sealed interface AttachmentSourcePicker {
object AllMedia : AttachmentSourcePicker
object Camera : AttachmentSourcePicker
}

View file

@ -31,5 +31,6 @@ fun aMessageComposerState() = MessageComposerState(
text = StableCharSequence(""),
isFullScreen = false,
mode = MessageComposerMode.Normal(content = ""),
attachmentSourcePicker = null,
eventSink = {}
)

View file

@ -54,7 +54,7 @@ fun MessageComposerView(
onCloseSpecialMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.TakePhoto)
state.eventSink(MessageComposerEvents.AddAttachment)
},
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
</resources>

View file

@ -22,7 +22,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
@ -279,6 +281,103 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - Open attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.AddAttachment)
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.AllMedia)
}
}
@Test
fun `present - Open camera attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera)
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.Camera)
}
}
@Test
fun `present - Dismiss attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.AddAttachment)
skipItems(1)
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
assertThat(awaitItem().attachmentSourcePicker).isNull()
}
}
@Test
fun `present - Pick media from gallery`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// TODO verify some post processing of the selected media is done
}
}
@Test
fun `present - Pick file from storage`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
// TODO verify some post processing of the selected media is done
}
}
@Test
fun `present - Take photo`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
@ -292,11 +391,30 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.TakePhoto)
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
// TODO verify some post processing of the captured image is done
}
}
@Test
fun `present - Record video`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
// TODO verify some post processing of the captured video is done
}
}
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)

View file

@ -6,7 +6,6 @@
</plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
@ -14,6 +13,7 @@
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</string>

View file

@ -19,9 +19,17 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetDefaults
import androidx.compose.material.ModalBottomSheetState
@ -30,12 +38,14 @@ import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -46,15 +56,36 @@ fun ModalBottomSheetLayout(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large,
sheetShape: Shape = MaterialTheme.shapes.large.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
displayHandle: Boolean = false,
useSystemPadding: Boolean = true,
content: @Composable () -> Unit = {}
) {
androidx.compose.material.ModalBottomSheetLayout(
sheetContent = sheetContent,
sheetContent = {
Column(
Modifier.fillMaxWidth()
.applyIf(useSystemPadding, ifTrue = {
navigationBarsPadding()
})
) {
if (displayHandle) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))
.size(width = 32.dp, height = 4.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(24.dp))
}
sheetContent()
}
},
modifier = modifier,
sheetState = sheetState,
sheetShape = sheetShape,
@ -79,10 +110,13 @@ internal fun ModalBottomSheetLayoutDarkPreview() =
@Composable
private fun ContentToPreview() {
ModalBottomSheetLayout(
modifier = Modifier.height(100.dp),
modifier = Modifier.height(140.dp),
displayHandle = true,
sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded),
sheetContent = {
Text(text = "Sheet Content", modifier = Modifier.padding(16.dp).background(color = Color.Green))
Text(text = "Sheet Content", modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp)
.background(color = Color.Green))
}
) {
Text(text = "Content", modifier = Modifier.background(color = Color.Red))

View file

@ -25,6 +25,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -55,8 +57,9 @@ fun TextComposer(
if (LocalInspectionMode.current) {
FakeComposer(modifier)
} else {
val focusRequester = FocusRequester()
AndroidView(
modifier = modifier,
modifier = modifier.focusRequester(focusRequester),
factory = { context ->
RichTextComposerLayout(context).apply {
// Sets up listeners for View -> Compose communication

View file

@ -95,14 +95,10 @@
<string name="room_timeline_read_marker_title">"Neu"</string>
<string name="screen_analytics_settings_share_data">"Teile Analyse-Daten"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_room_member_details_block_alert_action">"Blockieren"</string>
<string name="screen_room_member_details_block_user">"Nutzer blockieren"</string>
<string name="screen_room_member_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_room_member_details_unblock_user">"Nutzer entblockieren"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
<string name="test_language_identifier">"de"</string>
<string name="dialog_title_error">"Fehler"</string>
<string name="dialog_title_success">"Erfolg"</string>
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
</resources>
</resources>

View file

@ -128,12 +128,6 @@
<string name="room_timeline_beginning_of_room_no_name">"Este es el principio de esta conversación."</string>
<string name="room_timeline_read_marker_title">"Nuevos"</string>
<string name="screen_report_content_block_user_hint">"Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
<string name="screen_room_member_details_block_alert_action">"Bloquear"</string>
<string name="screen_room_member_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."</string>
<string name="screen_room_member_details_block_user">"Bloquear usuario"</string>
<string name="screen_room_member_details_unblock_alert_action">"Desbloquear"</string>
<string name="screen_room_member_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string>
<string name="screen_room_member_details_unblock_user">"Desbloquear usuario"</string>
<string name="settings_rageshake">"Agitar con fuerza"</string>
<string name="settings_rageshake_detection_threshold">"Umbral de detección"</string>
<string name="settings_title_general">"General"</string>
@ -142,4 +136,4 @@
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Terminado"</string>
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
</resources>
</resources>

View file

@ -128,12 +128,6 @@
<string name="room_timeline_beginning_of_room_no_name">"Questo è l\'inizio della conversazione."</string>
<string name="room_timeline_read_marker_title">"Nuovo"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_room_member_details_block_alert_action">"Blocca"</string>
<string name="screen_room_member_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string>
<string name="screen_room_member_details_block_user">"Blocca utente"</string>
<string name="screen_room_member_details_unblock_alert_action">"Sblocca"</string>
<string name="screen_room_member_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string>
<string name="screen_room_member_details_unblock_user">"Sblocca utente"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Soglia di rilevamento"</string>
<string name="settings_title_general">"Generali"</string>
@ -142,4 +136,4 @@
<string name="dialog_title_error">"Errore"</string>
<string name="dialog_title_success">"Operazione riuscita"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
</resources>
</resources>

View file

@ -143,12 +143,6 @@
<string name="screen_analytics_prompt_third_party_sharing"><b>"Nu"</b>" împărtășim informații cu terți"</string>
<string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_room_member_details_block_alert_action">"Blocați"</string>
<string name="screen_room_member_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string>
<string name="screen_room_member_details_block_user">"Blocați utilizatorul"</string>
<string name="screen_room_member_details_unblock_alert_action">"Deblocați"</string>
<string name="screen_room_member_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_room_member_details_unblock_user">"Deblocați utilizatorul"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Prag de detecție"</string>
<string name="settings_title_general">"General"</string>
@ -160,4 +154,4 @@
<string name="screen_analytics_settings_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"aici"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
</resources>
</resources>

View file

@ -147,12 +147,6 @@
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string>
<string name="screen_room_member_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_room_member_details_block_user">"Block user"</string>
<string name="screen_room_member_details_unblock_alert_action">"Unblock"</string>
<string name="screen_room_member_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_room_member_details_unblock_user">"Unblock user"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b
size 4483
oid sha256:e3cb476c16c2cae9f3230cc4030b66662b9f63cef22208fc5bf577d0b16bf946
size 4484

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b
size 4483
oid sha256:e3cb476c16c2cae9f3230cc4030b66662b9f63cef22208fc5bf577d0b16bf946
size 4484

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db69f27f60dd9d93bb4d313741b84aa4a3ed008d229590338514c7683c0e3a11
size 14786
oid sha256:dea394d708a714603ea77543a7ab31550baaea72c75255c56ac9162589096128
size 14453

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e
size 4490
oid sha256:54b434198b8b6b534e0e82310e58eec162f18aba876f0dca9a1790c137230595
size 4496

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e
size 4490
oid sha256:54b434198b8b6b534e0e82310e58eec162f18aba876f0dca9a1790c137230595
size 4496

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c630475e03d86195a0ebcc57bd12934b799fee956c635b30df60913cd9a3f50
size 16032
oid sha256:f3080445c87d85fd5c51228e33ef7f91eb3a718f2f8288bdfa2a48d6769a25a1
size 15480

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d6de6a4dee8a62839c15a84de8cb9817b3de8ae9fd2317e723c47bb679a72b7d
size 39603
oid sha256:01f54909964a4ed07d8850ab2bffad8b99ed641d731c1808b04f164ff3e0cbba
size 39632

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d8f233685a21a72081b24ee91bc90e94b340c39f7e2c498f76a69e5b7e129ff
size 41480
oid sha256:4ea39fdf1cb61657bc1eafb7d353ee15517c26c0e6bb2804b7c8350fa651299e
size 41508

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7d7470933e27c64182c5f8acec7b4ce3896db1cc3836ff6aa6cf33fe8688f10
size 37524

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac05963f89ac0d3045700447a40a4a6f38cc155812de7eb9a87fb9a01643033
size 36300

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:caaf172ddb39cbd1a77bfb2295202e5c0aa95c846353a17da6abe6c50316cf63
size 38510
oid sha256:c6c558d2a8e6adb4831b91aff249d16dfc48389adef5ada6e1215df1d597639a
size 38654

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:91a5fe69790f195bba47a878efb1d93a4e35bff9ec11f262cdf9c237b4cc3639
size 40563
oid sha256:946802d97d29f9fe41a71f1ee343e298e1b0d9dbbc6561cc82fed712e623f89c
size 40708

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97f10ca5290a0b4b1e034e157abb0f069fac2f2c2e88234edb1efc25c6c111f0
size 36228

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cfc76bb6a8d8b269f53c2cc1c369a5c5ffb0f0fb858ebfa50c613a793bc1b421
size 35195

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b36389c997a7796eb7c070d4927c9a5197bca08b24cf58797de9d622c8a6176
size 13945
oid sha256:5eb57bf5069755caaa2188db848517a915b298ec3ba72334e27c90b354d0b4ef
size 14302

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1579ab462c62f872774fbdb820da1f6abc09e4b8e6fb2e3baaf3d46a0722ed94
size 15471
oid sha256:4c37402ebdad96583f879614d07ea45f5193f6976ce5f2382333106dda77f095
size 15520

View file

@ -85,6 +85,18 @@
"screen_room_member_list_.*",
"screen_dm_details_.*"
]
},
{
"name": ":features:messages:impl",
"includeRegex": [
"screen_room_.*",
"screen_dm_details_.*"
],
"excludeRegex": [
"screen_room_details_.*",
"screen_room_member.*",
"screen_dm_.*"
]
}
]
}

View file

@ -36,10 +36,13 @@ allActions = []
# Iterating on the config
for entry in config["modules"]:
# Create action for the default language
excludeRegex = regexToAlwaysExclude
if "excludeRegex" in entry:
excludeRegex += entry["excludeRegex"]
action = baseAction | {
"output": convertModuleToPath(entry["name"]) + "/src/main/res/values/localazy.xml",
"includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
"excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)),
"excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)),
"conditions": [
"equals: ${languageCode}, en"
]
@ -51,7 +54,7 @@ for entry in config["modules"]:
actionTranslation = baseAction | {
"output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml",
"includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
"excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)),
"excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)),
"conditions": [
"!equals: ${languageCode}, en"
]