[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:
Jorge Martin Espinosa 2023-06-02 14:08:54 +02:00 committed by GitHub
parent 2f0727363a
commit 0009409a3b
42 changed files with 657 additions and 299 deletions

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

@ -0,0 +1 @@
Redesign the timeline item context menu using M3 bottom sheet

View file

@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState) TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> notImplementedYet()
TimelineItemAction.ReportContent -> notImplementedYet()
} }
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight 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.Icons
import androidx.compose.material.icons.filled.ArrowBack 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.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment 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.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.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.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView 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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun MessagesView( fun MessagesView(
state: MessagesState, state: MessagesState,
@ -103,26 +94,11 @@ fun MessagesView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") 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() val coroutineScope = rememberCoroutineScope()
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch {
bottomSheetState.hide()
}
}
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) } val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) { if (snackbarMessageText != null) {
@ -150,78 +126,57 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}") Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard() localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
coroutineScope.launch { isMessageActionsBottomSheetVisible = true
itemActionsBottomSheetState.show()
}
} }
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
isMessageActionsBottomSheetVisible = false
state.eventSink(MessagesEvents.HandleAction(action, event)) state.eventSink(MessagesEvents.HandleAction(action, event))
} }
LaunchedEffect(composerState.showAttachmentSourcePicker) { fun onDismissActionListBottomSheet() {
if (composerState.showAttachmentSourcePicker) { isMessageActionsBottomSheetVisible = false
// 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 = {
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( Scaffold(
state = state.actionListState, modifier = modifier,
modalBottomSheetState = itemActionsBottomSheetState, contentWindowInsets = WindowInsets.statusBars,
onActionSelected = ::onActionSelected 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 @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 @Preview
@Composable @Composable
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =

View file

@ -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.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> { class ActionListPresenter @Inject constructor(
private val buildMeta: BuildMeta,
) : Presenter<ActionListState> {
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
when (timelineItem.content) { when (timelineItem.content) {
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
// TODO Add Share action (also) here, and developer options buildList {
emptyList() add(TimelineItemAction.Copy)
} if (buildMeta.isDebuggable) {
else -> { add(TimelineItemAction.Developer)
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
} }
} }
} }
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()) target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
} }

View file

@ -16,52 +16,81 @@
package io.element.android.features.messages.impl.actionlist package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding 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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import kotlinx.coroutines.flow.filter import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.coroutines.launch import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ActionListView( fun ActionListView(
state: ActionListState, state: ActionListState,
modalBottomSheetState: ModalBottomSheetState, isVisible: Boolean,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val coroutineScope = rememberCoroutineScope() LaunchedEffect(isVisible) {
LaunchedEffect(modalBottomSheetState) { if (!isVisible) {
snapshotFlow { modalBottomSheetState.currentValue } state.eventSink(ActionListEvents.Clear)
.filter { it == ModalBottomSheetValue.Hidden } }
.collect {
state.eventSink(ActionListEvents.Clear)
}
} }
fun onItemActionClicked( fun onItemActionClicked(
@ -69,24 +98,22 @@ fun ActionListView(
targetItem: TimelineItem.Event targetItem: TimelineItem.Event
) { ) {
onActionSelected(itemAction, targetItem) onActionSelected(itemAction, targetItem)
coroutineScope.launch {
modalBottomSheetState.hide()
}
} }
ModalBottomSheetLayout( if (isVisible) {
modifier = modifier, ModalBottomSheet(
sheetState = modalBottomSheetState, onDismissRequest = onDismiss
sheetContent = { ) {
SheetContent( SheetContent(
state = state, state = state,
onActionClicked = ::onItemActionClicked, onActionClicked = ::onItemActionClicked,
modifier = Modifier modifier = modifier
.navigationBarsPadding() .padding(bottom = 32.dp)
.imePadding() // .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
) )
} }
) }
} }
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@ -108,6 +135,19 @@ private fun SheetContent(
LazyColumn( LazyColumn(
modifier = modifier.fillMaxWidth() 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(
items = actions, items = actions,
) { action -> ) { 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 @Preview
@Composable @Composable
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun ContentToPreview(state: ActionListState) { private fun ContentToPreview(state: ActionListState) {
ActionListView( SheetContent(state = state)
state = state,
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
onActionSelected = { _, _ -> }
)
} }

View file

@ -26,9 +26,11 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val destructive: Boolean = false val destructive: Boolean = false
) { ) {
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward) object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy) object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true) object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply) object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit) object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
} }

View file

@ -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)) },
)
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -46,21 +47,25 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.UpdateText(text)) state.eventSink(MessageComposerEvents.UpdateText(text))
} }
TextComposer( Box {
onSendMessage = ::sendMessage, AttachmentsBottomSheet(state = state)
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle, TextComposer(
composerMode = state.mode, onSendMessage = ::sendMessage,
onCloseSpecialMode = ::onCloseSpecialMode, fullscreen = state.isFullScreen,
onComposerTextChange = ::onComposerTextChange, onFullscreenToggle = ::onFullscreenToggle,
onAddAttachment = { composerMode = state.mode,
state.eventSink(MessageComposerEvents.AddAttachment) onCloseSpecialMode = ::onCloseSpecialMode,
}, onComposerTextChange = ::onComposerTextChange,
composerCanSendMessage = state.isSendButtonVisible, onAddAttachment = {
composerText = state.text?.charSequence?.toString(), state.eventSink(MessageComposerEvents.AddAttachment)
isInDarkMode = !ElementTheme.colors.isLight, },
modifier = modifier composerCanSendMessage = state.isSendButtonVisible,
) composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}
} }
@Preview @Preview

View file

@ -24,11 +24,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Attachment import androidx.compose.material.icons.filled.Attachment
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -57,8 +59,9 @@ fun TimelineItemFileView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Attachment, imageVector = Icons.Outlined.Attachment,
contentDescription = "OpenFile" contentDescription = "OpenFile",
modifier = Modifier.size(16.dp).rotate(-45f),
) )
} }
Text( Text(

View file

@ -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.impl.timeline.TimelinePresenter
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor 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.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom 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( private fun TestScope.createMessagePresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom() matrixRoom: MatrixRoom = FakeMatrixRoom()
): MessagesPresenter { ): MessagesPresenter {
@ -148,7 +176,20 @@ class MessagesPresenterTest {
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, 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( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,

View file

@ -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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -42,7 +44,7 @@ import org.junit.Test
class ActionListPresenterTest { class ActionListPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -53,7 +55,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from me redacted`() = runTest { fun `present - compute for message from me redacted`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -67,6 +69,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
) )
) )
) )
@ -75,9 +79,10 @@ class ActionListPresenterTest {
} }
} }
@Test @Test
fun `present - compute for message from others redacted`() = runTest { fun `present - compute for message from others redacted`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -91,6 +96,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
) )
) )
) )
@ -101,7 +108,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message`() = runTest { fun `present - compute for others message`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -121,6 +128,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
) )
) )
) )
@ -131,7 +140,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message`() = runTest { fun `present - compute for my message`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -150,8 +159,41 @@ class ActionListPresenterTest {
persistentListOf( persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit, 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, 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( private fun aMessageEvent(
isMine: Boolean, isMine: Boolean,
content: TimelineItemEventContent, content: TimelineItemEventContent,

View file

@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
object VectorIcons { object VectorIcons {
val Copy = R.drawable.ic_content_copy val Copy = R.drawable.ic_content_copy
val ArrowForward = R.drawable.ic_content_arrow_forward val Forward = R.drawable.ic_forward
val Delete = R.drawable.ic_baseline_delete_outline_24 val Delete = R.drawable.ic_delete
val Reply = R.drawable.ic_baseline_reply_24 val Reply = R.drawable.ic_reply
val Edit = R.drawable.ic_baseline_edit_24 val Edit = R.drawable.ic_edit
val DoorOpen = R.drawable.ic_door_open_24 val DoorOpen = R.drawable.ic_door_open_24
val DeveloperMode = R.drawable.ic_developer_mode
val ReportContent = R.drawable.ic_report_content
} }

View file

@ -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>

View file

@ -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>

View 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>

View 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="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>

View 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>

View 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>

View 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>

View 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="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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:82871e8bf4472e48dd134d55c4ef24a490643e022da3101342fa47ca89024adb oid sha256:0e4ddf896da22a54df8584595a02c8aea737c7c306b20d496a3cc5e7aa0bb892
size 7323 size 7630

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c3173576742c91fea1f6825a83e08c85636d14cd6ef9d84ff9c402df7d62eee9 oid sha256:aa8f48f1f8167755f27a67fc634f854b13f1ee78fe7c425a8e7285df03e937d7
size 9936 size 10189

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1ed4bfb88bb5d1a037d0393caf056cb30c23df14a6258dac5b7cdfd2435e6ddf oid sha256:94300042d18138cea4e3d7462c3a1492bbc8766292bbf4bdc0b22802f199c52e
size 12614 size 12882

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:764bbe03b664b697f2e1bbb93ef879060819b41d8b9f8620ad7f64883c5892c2 oid sha256:55e7f102412306ca4ff8e0519f08c3e5c7a822629bd6786304b89be82bcaffaf
size 6890 size 7132

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2dbc148e817289cc726fe4fbbba09c0a2b43e794bde2673841ad9062ebfc3ec5 oid sha256:2533688d2cd343c4466d7b1024451db5936e94c34ba6a9e685be5fc27ca05f40
size 8979 size 9216

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c5207aa4f89d0613937318de8cf6e324c19ab4b769eca0519f375ab505687b4c oid sha256:0a139e4bf9c9b0bb3696e154fb0e56f67e00df6a3673b24f12f8dae6606833dc
size 11256 size 11464

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f8f54ea6a7d158e8f7da56648aabf05c8dbc761db8698891d2d3f1ff863b76fb oid sha256:c9e411482e83d74323faec8b14410ec48ad9a2ba7ec731de7192f583608cecc6
size 51724 size 53465

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4c2845cd6ef914a8062d3685d62d6b861a3963e09aad13efd5a9ac5e2de1a3ea oid sha256:e9f2ee31c8c1a8183a3314b2f9310dcb737b4f9b73ab99b1a41ddecf4f52d3b9
size 73507 size 75291

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:022ca7f2b5ce17e0079346778ee3b99b1e235439f88885a2d1bf545cc11864e6 oid sha256:ea99af12122d317652b23751542d751463e555ef16418ceaf2ec83be48bcc9a0
size 51800 size 53241

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c347c51252c8e1305385c6c92936941403c481c792ba3fe10ca4949a1accd051 oid sha256:6fdabd6188d749aa9fd8e1e1c54432399a8d25ae1b3d59a200d07e4370b1dbe6
size 74080 size 75518

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:74d763ff8d44e686ee3a10dcadc0e2c1f02e3332c486d62d00e2333d0c3b60cf oid sha256:996ca55692e56818c1473c991b6252f0886f1bfbb5511d9d35f5fc38a878e490
size 45578 size 45546

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:09b29e2e7bb8c38c978d3b56b04a97fc96d752154bb78fddd13a5cde8051a048 oid sha256:ac450ec2a16b34217538c4304db4cd57645c676b1b44f510d4672ad47beffc57
size 45757 size 45725

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a936d98080f35c838adab660ef9b9287fc380d1358335c54e3da56230476721f oid sha256:996ca55692e56818c1473c991b6252f0886f1bfbb5511d9d35f5fc38a878e490
size 43810 size 45546

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:43042ccb540b5fa3b8d5d9e273bd1155258d0dc12875b95c7a364492b3985da2 oid sha256:de4068d875e93b5cc69d4c55e9dff3a32104514e7efad791f753e5ab77e96b90
size 44854 size 44665

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:16cf470ec6ff50ee469db7bdc2cbfc1e32c5bcd7835c12c7313aafa495a44825 oid sha256:7744199792a34d1fe284867b9bf2878bfa81a05eb34ccd50f8bd33662408fa8b
size 45282 size 45096

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e6cc29403f3b3e146e9a637973320f5bd6bf73feb7b042f8844337817e551113 oid sha256:de4068d875e93b5cc69d4c55e9dff3a32104514e7efad791f753e5ab77e96b90
size 42280 size 44665