[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 9072305735
commit dc4b385ada
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.Edit -> handleActionEdit(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
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) =

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.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())
}

View file

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

View file

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

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

View file

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

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.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,

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.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,

View file

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

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
oid sha256:e3cb476c16c2cae9f3230cc4030b66662b9f63cef22208fc5bf577d0b16bf946
size 4484
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9
size 4478

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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