[Message actions] Update timeline context menu (#498)
* Replace bottom sheets in timeline with M3 ones. * Add missing options to menu with the right icons * Add message summary and reaction emoji row. * Use the same icon in the message summary and file timeline item
This commit is contained in:
committed by
GitHub
parent
2f0727363a
commit
0009409a3b
1
changelog.d/483.feature
Normal file
1
changelog.d/483.feature
Normal file
@@ -0,0 +1 @@
|
||||
Redesign the timeline item context menu using M3 bottom sheet
|
||||
@@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||
TimelineItemAction.Developer -> notImplementedYet()
|
||||
TimelineItemAction.ReportContent -> notImplementedYet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
@@ -32,27 +31,22 @@ import androidx.compose.foundation.layout.padding
|
||||
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
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Collections
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
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.SideEffect
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
@@ -66,9 +60,7 @@ 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.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
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
|
||||
@@ -80,7 +72,6 @@ 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
|
||||
@@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import io.element.android.libraries.ui.strings.R as StringsR
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MessagesView(
|
||||
state: MessagesState,
|
||||
@@ -103,26 +94,11 @@ fun MessagesView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val composerState = state.composerState
|
||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
|
||||
ModalBottomSheetValue.Expanded
|
||||
} else {
|
||||
ModalBottomSheetValue.Hidden
|
||||
}
|
||||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||
|
||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
||||
coroutineScope.launch {
|
||||
bottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
||||
if (snackbarMessageText != null) {
|
||||
@@ -150,78 +126,57 @@ fun MessagesView(
|
||||
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||
localView.hideKeyboard()
|
||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
isMessageActionsBottomSheetVisible = true
|
||||
}
|
||||
|
||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||
isMessageActionsBottomSheetVisible = false
|
||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||
}
|
||||
|
||||
LaunchedEffect(composerState.showAttachmentSourcePicker) {
|
||||
if (composerState.showAttachmentSourcePicker) {
|
||||
// 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()
|
||||
}
|
||||
fun onDismissActionListBottomSheet() {
|
||||
isMessageActionsBottomSheetVisible = false
|
||||
}
|
||||
// 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 = {
|
||||
AttachmentSourcePickerMenu(
|
||||
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)
|
||||
.consumeWindowInsets(padding),
|
||||
onMessageClicked = ::onMessageClicked,
|
||||
onMessageLongClicked = ::onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
snackbarHostState,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
onActionSelected = ::onActionSelected
|
||||
)
|
||||
}
|
||||
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)
|
||||
.consumeWindowInsets(padding),
|
||||
onMessageClicked = ::onMessageClicked,
|
||||
onMessageLongClicked = ::onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
snackbarHostState,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
isVisible = isMessageActionsBottomSheetVisible,
|
||||
onDismiss = ::onDismissActionListBottomSheet,
|
||||
onActionSelected = ::onActionSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -312,36 +267,6 @@ fun MessagesViewTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
||||
|
||||
@@ -26,12 +26,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||
class ActionListPresenter @Inject constructor(
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<ActionListState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ActionListState {
|
||||
@@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||
when (timelineItem.content) {
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemStateContent -> {
|
||||
// TODO Add Share action (also) here, and developer options
|
||||
emptyList()
|
||||
}
|
||||
else -> {
|
||||
mutableListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
).also {
|
||||
if (timelineItem.isMine) {
|
||||
it.add(TimelineItemAction.Edit)
|
||||
it.add(TimelineItemAction.Redact)
|
||||
buildList {
|
||||
add(TimelineItemAction.Copy)
|
||||
if (buildMeta.isDebuggable) {
|
||||
add(TimelineItemAction.Developer)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> buildList<TimelineItemAction> {
|
||||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
if (timelineItem.isMine) {
|
||||
add(TimelineItemAction.Edit)
|
||||
}
|
||||
add(TimelineItemAction.Copy)
|
||||
if (buildMeta.isDebuggable) {
|
||||
add(TimelineItemAction.Developer)
|
||||
}
|
||||
if (!timelineItem.isMine) {
|
||||
add(TimelineItemAction.ReportContent)
|
||||
}
|
||||
if (timelineItem.isMine) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}
|
||||
}
|
||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||
}
|
||||
|
||||
@@ -16,52 +16,81 @@
|
||||
|
||||
package io.element.android.features.messages.impl.actionlist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
import androidx.compose.material.icons.outlined.VideoCameraBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
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.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
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.TimelineItemProfileChangeContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Divider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ActionListView(
|
||||
state: ActionListState,
|
||||
modalBottomSheetState: ModalBottomSheetState,
|
||||
isVisible: Boolean,
|
||||
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(modalBottomSheetState) {
|
||||
snapshotFlow { modalBottomSheetState.currentValue }
|
||||
.filter { it == ModalBottomSheetValue.Hidden }
|
||||
.collect {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
}
|
||||
LaunchedEffect(isVisible) {
|
||||
if (!isVisible) {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemActionClicked(
|
||||
@@ -69,24 +98,22 @@ fun ActionListView(
|
||||
targetItem: TimelineItem.Event
|
||||
) {
|
||||
onActionSelected(itemAction, targetItem)
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
modifier = modifier,
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetContent = {
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
SheetContent(
|
||||
state = state,
|
||||
onActionClicked = ::onItemActionClicked,
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
modifier = modifier
|
||||
.padding(bottom = 32.dp)
|
||||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||
// .imePadding()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@@ -108,6 +135,19 @@ private fun SheetContent(
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
MessageSummary(event = target.event, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp))
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
item {
|
||||
EmojiReactionsRow(Modifier.fillMaxWidth())
|
||||
Divider()
|
||||
}
|
||||
items(
|
||||
items = actions,
|
||||
) { action ->
|
||||
@@ -135,6 +175,141 @@ private fun SheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
|
||||
val content: @Composable () -> Unit
|
||||
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) }
|
||||
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
|
||||
val imageModifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
|
||||
@Composable
|
||||
fun ContentForBody(body: String) {
|
||||
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
when (event.content) {
|
||||
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
|
||||
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
|
||||
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
|
||||
is TimelineItemImageContent -> {
|
||||
icon = {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = event.content.mediaSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||
)
|
||||
BlurHashAsyncImage(
|
||||
model = mediaRequestData,
|
||||
blurHash = event.content.blurhash,
|
||||
contentDescription = stringResource(StringR.string.common_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = imageModifier,
|
||||
)
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
icon = {
|
||||
val thumbnailSource = event.content.thumbnailSource
|
||||
if (thumbnailSource != null) {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = event.content.thumbnailSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||
)
|
||||
BlurHashAsyncImage(
|
||||
model = mediaRequestData,
|
||||
blurHash = event.content.blurHash,
|
||||
contentDescription = stringResource(StringR.string.common_video),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = imageModifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.VideoCameraBack,
|
||||
contentDescription = stringResource(StringR.string.common_video),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemFileContent -> {
|
||||
icon = {
|
||||
Box(
|
||||
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = stringResource(StringR.string.common_file),
|
||||
modifier = Modifier.rotate(-45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
icon()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Row {
|
||||
if (event.senderDisplayName != null) {
|
||||
Text(
|
||||
text = event.senderDisplayName,
|
||||
style = ElementTextStyles.Bold.caption1,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
event.sentTime,
|
||||
style = ElementTextStyles.Regular.caption2,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
// TODO use real emojis, have real interaction
|
||||
Text("\uD83D\uDC4D", fontSize = 28.dpToSp())
|
||||
Text("\uD83D\uDC4E", fontSize = 28.dpToSp())
|
||||
Text("\uD83D\uDD25", fontSize = 28.dpToSp())
|
||||
Text("❤\uFE0F", fontSize = 28.dpToSp())
|
||||
Text("\uD83D\uDC4F", fontSize = 28.dpToSp())
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AddReaction,
|
||||
contentDescription = "Emojis",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
|
||||
return dp.toSp()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||
@@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
|
||||
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun ContentToPreview(state: ActionListState) {
|
||||
ActionListView(
|
||||
state = state,
|
||||
modalBottomSheetState = ModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Expanded
|
||||
),
|
||||
onActionSelected = { _, _ -> }
|
||||
)
|
||||
SheetContent(state = state)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ sealed class TimelineItemAction(
|
||||
@DrawableRes val icon: Int,
|
||||
val destructive: Boolean = false
|
||||
) {
|
||||
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
|
||||
object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
|
||||
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
||||
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
||||
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
||||
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
||||
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
|
||||
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Collections
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AttachmentsBottomSheet(
|
||||
state: MessageComposerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val localView = LocalView.current
|
||||
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
|
||||
|
||||
BackHandler(enabled = isVisible) {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(state.showAttachmentSourcePicker) {
|
||||
if (state.showAttachmentSourcePicker) {
|
||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||
localView.hideKeyboard()
|
||||
isVisible = true
|
||||
} else {
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||
LaunchedEffect(isVisible) {
|
||||
if (!isVisible) {
|
||||
state.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = { isVisible = false }
|
||||
) {
|
||||
AttachmentSourcePickerMenu(eventSink = state.eventSink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.padding(bottom = 32.dp)
|
||||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -46,21 +47,25 @@ fun MessageComposerView(
|
||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
onSendMessage = ::sendMessage,
|
||||
fullscreen = state.isFullScreen,
|
||||
onFullscreenToggle = ::onFullscreenToggle,
|
||||
composerMode = state.mode,
|
||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
||||
onComposerTextChange = ::onComposerTextChange,
|
||||
onAddAttachment = {
|
||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||
},
|
||||
composerCanSendMessage = state.isSendButtonVisible,
|
||||
composerText = state.text?.charSequence?.toString(),
|
||||
isInDarkMode = !ElementTheme.colors.isLight,
|
||||
modifier = modifier
|
||||
)
|
||||
Box {
|
||||
AttachmentsBottomSheet(state = state)
|
||||
|
||||
TextComposer(
|
||||
onSendMessage = ::sendMessage,
|
||||
fullscreen = state.isFullScreen,
|
||||
onFullscreenToggle = ::onFullscreenToggle,
|
||||
composerMode = state.mode,
|
||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
||||
onComposerTextChange = ::onComposerTextChange,
|
||||
onAddAttachment = {
|
||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||
},
|
||||
composerCanSendMessage = state.isSendButtonVisible,
|
||||
composerText = state.text?.charSequence?.toString(),
|
||||
isInDarkMode = !ElementTheme.colors.isLight,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -24,11 +24,13 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Attachment
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -57,8 +59,9 @@ fun TimelineItemFileView(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Attachment,
|
||||
contentDescription = "OpenFile"
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = "OpenFile",
|
||||
modifier = Modifier.size(16.dp).rotate(-45f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
@@ -132,6 +134,32 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action report content`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||
// Still a TODO in the code
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action show developer info`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||
// Still a TODO in the code
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createMessagePresenter(
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
||||
): MessagesPresenter {
|
||||
@@ -148,7 +176,20 @@ class MessagesPresenterTest {
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
)
|
||||
val actionListPresenter = ActionListPresenter()
|
||||
val buildMeta = BuildMeta(
|
||||
buildType = BuildType.DEBUG,
|
||||
isDebuggable = true,
|
||||
applicationId = "",
|
||||
applicationName = "",
|
||||
lowPrivacyLoggingEnabled = true,
|
||||
versionName = "",
|
||||
gitRevision = "",
|
||||
gitBranchName = "",
|
||||
gitRevisionDate = "",
|
||||
flavorDescription = "",
|
||||
flavorShortDescription = "",
|
||||
)
|
||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||
return MessagesPresenter(
|
||||
room = matrixRoom,
|
||||
composerPresenter = messageComposerPresenter,
|
||||
|
||||
@@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -42,7 +44,7 @@ import org.junit.Test
|
||||
class ActionListPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = ActionListPresenter()
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -53,7 +55,7 @@ class ActionListPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - compute for message from me redacted`() = runTest {
|
||||
val presenter = ActionListPresenter()
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -67,6 +69,8 @@ class ActionListPresenterTest {
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -75,9 +79,10 @@ class ActionListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `present - compute for message from others redacted`() = runTest {
|
||||
val presenter = ActionListPresenter()
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -91,6 +96,8 @@ class ActionListPresenterTest {
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -101,7 +108,7 @@ class ActionListPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - compute for others message`() = runTest {
|
||||
val presenter = ActionListPresenter()
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -121,6 +128,8 @@ class ActionListPresenterTest {
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -131,7 +140,7 @@ class ActionListPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - compute for my message`() = runTest {
|
||||
val presenter = ActionListPresenter()
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -150,8 +159,41 @@ class ActionListPresenterTest {
|
||||
persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute message in non-debuggable build`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||
// val loadingState = awaitItem()
|
||||
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
@@ -162,6 +204,34 @@ class ActionListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun aBuildMeta(
|
||||
buildType: BuildType = BuildType.DEBUG,
|
||||
isDebuggable: Boolean = true,
|
||||
applicationName: String = "",
|
||||
applicationId: String = "",
|
||||
lowPrivacyLoggingEnabled: Boolean = true,
|
||||
versionName: String = "",
|
||||
gitRevision: String = "",
|
||||
gitRevisionDate: String = "",
|
||||
gitBranchName: String = "",
|
||||
flavorDescription: String = "",
|
||||
flavorShortDescription: String = "",
|
||||
) = BuildMeta(
|
||||
buildType,
|
||||
isDebuggable,
|
||||
applicationName,
|
||||
applicationId,
|
||||
lowPrivacyLoggingEnabled,
|
||||
versionName,
|
||||
gitRevision,
|
||||
gitRevisionDate,
|
||||
gitBranchName,
|
||||
flavorDescription,
|
||||
flavorShortDescription
|
||||
)
|
||||
|
||||
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||
|
||||
private fun aMessageEvent(
|
||||
isMine: Boolean,
|
||||
content: TimelineItemEventContent,
|
||||
|
||||
@@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
|
||||
|
||||
object VectorIcons {
|
||||
val Copy = R.drawable.ic_content_copy
|
||||
val ArrowForward = R.drawable.ic_content_arrow_forward
|
||||
val Delete = R.drawable.ic_baseline_delete_outline_24
|
||||
val Reply = R.drawable.ic_baseline_reply_24
|
||||
val Edit = R.drawable.ic_baseline_edit_24
|
||||
val Forward = R.drawable.ic_forward
|
||||
val Delete = R.drawable.ic_delete
|
||||
val Reply = R.drawable.ic_reply
|
||||
val Edit = R.drawable.ic_edit
|
||||
val DoorOpen = R.drawable.ic_door_open_24
|
||||
val DeveloperMode = R.drawable.ic_developer_mode
|
||||
val ReportContent = R.drawable.ic_report_content
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8,9h8v10L8,19L8,9zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z" />
|
||||
</vector>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
||||
</vector>
|
||||
10
libraries/designsystem/src/main/res/drawable/ic_delete.xml
Normal file
10
libraries/designsystem/src/main/res/drawable/ic_delete.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M344,664L160,480L344,296L400,354L274,480L400,606L344,664ZM200,680L280,680L280,720L680,720L680,680L760,680L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920Q247,920 223.5,896.5Q200,873 200,840L200,680ZM280,280L200,280L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,280L680,280L680,240L280,240L280,280ZM280,800L280,840Q280,840 280,840Q280,840 280,840L680,840Q680,840 680,840Q680,840 680,840L680,800L280,800ZM280,160L680,160L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,160ZM616,664L560,606L686,480L560,354L616,296L800,480L616,664ZM280,160L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,160L280,160ZM280,800L280,800L280,840Q280,840 280,840Q280,840 280,840L280,840Q280,840 280,840Q280,840 280,840L280,800Z"/>
|
||||
</vector>
|
||||
10
libraries/designsystem/src/main/res/drawable/ic_edit.xml
Normal file
10
libraries/designsystem/src/main/res/drawable/ic_edit.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
|
||||
</vector>
|
||||
11
libraries/designsystem/src/main/res/drawable/ic_forward.xml
Normal file
11
libraries/designsystem/src/main/res/drawable/ic_forward.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M120,760L120,600Q120,517 178.5,458.5Q237,400 320,400L688,400L544,256L600,200L840,440L600,680L544,624L688,480L320,480Q270,480 235,515Q200,550 200,600L200,760L120,760Z"/>
|
||||
</vector>
|
||||
11
libraries/designsystem/src/main/res/drawable/ic_reply.xml
Normal file
11
libraries/designsystem/src/main/res/drawable/ic_reply.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L272,480L416,624L360,680L120,440L360,200L416,256L272,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L160,640Z"/>
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user