Collapse long lists of message reactions (#806)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
54c7e8bb57
commit
d8fcfc5844
72 changed files with 387 additions and 254 deletions
|
|
@ -1,87 +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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.Surface
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun MessagesMoreReactionsButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val buttonColor = ElementTheme.colors.bgSubtleSecondary
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.background(Color.Transparent)
|
||||
// Outer border, same colour as background
|
||||
.border(
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(corner = CornerSize(14.dp))
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||
// Clip click indicator inside the outer border
|
||||
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.clickable(onClick = onClick)
|
||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||
color = buttonColor
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AddReaction,
|
||||
contentDescription = "Add emoji",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
// Same size as the line height of reaction emoji text
|
||||
.size(with(LocalDensity.current) { 20.sp.toDp() })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesMoreReactionsButtonLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesMoreReactionsButtonDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
MessagesMoreReactionsButton(onClick = {})
|
||||
}
|
||||
|
|
@ -22,40 +22,55 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val buttonColor = if (reaction.isHighlighted) {
|
||||
fun MessagesReactionButton(
|
||||
onClick: () -> Unit,
|
||||
content: MessagesReactionsButtonContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val buttonColor = if (content.isHighlighted) {
|
||||
ElementTheme.colors.bgSubtlePrimary
|
||||
} else {
|
||||
ElementTheme.colors.bgSubtleSecondary
|
||||
}
|
||||
val borderColor = if (reaction.isHighlighted) {
|
||||
|
||||
val borderColor = if (content.isHighlighted) {
|
||||
ElementTheme.colors.borderInteractivePrimary
|
||||
} else {
|
||||
buttonColor
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.background(Color.Transparent)
|
||||
|
|
@ -74,35 +89,91 @@ fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Mo
|
|||
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||
color = buttonColor
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = reaction.key, fontSize = 15.sp, lineHeight = 20.sp
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
when (content) {
|
||||
is MessagesReactionsButtonContent.Icon -> IconContent(imageVector = content.imageVector)
|
||||
is MessagesReactionsButtonContent.Text -> TextContent(text = content.text)
|
||||
is MessagesReactionsButtonContent.Reaction -> ReactionContent(reaction = content.reaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesReactionButtonLightPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) =
|
||||
ElementPreviewLight { ContentToPreview(reaction) }
|
||||
sealed class MessagesReactionsButtonContent {
|
||||
data class Text(val text: String) : MessagesReactionsButtonContent()
|
||||
data class Icon(val imageVector: ImageVector) : MessagesReactionsButtonContent()
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesReactionButtonDarkPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) =
|
||||
ElementPreviewDark { ContentToPreview(reaction) }
|
||||
data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent()
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(reaction: AggregatedReaction) {
|
||||
MessagesReactionButton(reaction, onClick = { })
|
||||
val isHighlighted get() = this is Reaction && reaction.isHighlighted
|
||||
}
|
||||
|
||||
private val reactionEmojiLineHeight = 20.sp
|
||||
|
||||
@Composable
|
||||
private fun TextContent(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Text(
|
||||
modifier = modifier
|
||||
.height(reactionEmojiLineHeight.toDp()),
|
||||
text = text,
|
||||
style = ElementTextStyles.Regular.bodyMD
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun IconContent(
|
||||
imageVector: ImageVector,
|
||||
modifier: Modifier = Modifier
|
||||
) = Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = modifier
|
||||
.size(reactionEmojiLineHeight.toDp())
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ReactionContent(
|
||||
reaction: AggregatedReaction,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = reaction.key,
|
||||
fontSize = 15.sp, lineHeight = reactionEmojiLineHeight
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
||||
Row {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = {}
|
||||
)
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text("12 more"),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ private fun TimelineItemEventRowContent(
|
|||
|
||||
// Reactions
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactionsView(
|
||||
TimelineItemReactions(
|
||||
reactionsState = event.reactionsState,
|
||||
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
|
||||
onReactionClicked = onReactionClicked,
|
||||
|
|
|
|||
|
|
@ -16,59 +16,184 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* The maximum number of items that can be displayed before some items will be hidden
|
||||
*
|
||||
* TODO The threshold should be based on the number of rows, rather than items.
|
||||
* Once items would spill onto a third row, they should be hidden.
|
||||
* Note this could be particularly worthwhile to handle reactions that are
|
||||
* longer than a single character (as annotation keys are free text).
|
||||
*/
|
||||
private const val COLLAPSE_ITEMS_THRESHOLD = 8
|
||||
|
||||
@Composable
|
||||
fun TimelineItemReactionsView(
|
||||
fun TimelineItemReactions(
|
||||
reactionsState: TimelineItemReactions,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val reactions by remember(reactionsState, expanded) {
|
||||
derivedStateOf {
|
||||
val numToDisplay = if (expanded) {
|
||||
reactionsState.reactions.count()
|
||||
} else {
|
||||
COLLAPSE_ITEMS_THRESHOLD
|
||||
}
|
||||
reactionsState.reactions.take(numToDisplay).toPersistentList()
|
||||
}
|
||||
}
|
||||
|
||||
val expandableState by remember {
|
||||
derivedStateOf {
|
||||
if (expanded) {
|
||||
ExpandableState.Expanded
|
||||
} else {
|
||||
val hiddenItems = reactionsState.reactions.count() - reactions.count()
|
||||
if (hiddenItems > 0) {
|
||||
ExpandableState.Collapsed(hidden = hiddenItems)
|
||||
} else {
|
||||
ExpandableState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimelineItemReactionsView(
|
||||
modifier = modifier,
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onExpandClick = { expanded = true },
|
||||
onCollapseClick = { expanded = false }
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class ExpandableState {
|
||||
object None: ExpandableState()
|
||||
data class Collapsed(val hidden: Int): ExpandableState()
|
||||
object Expanded : ExpandableState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemReactionsView(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
onMoreReactionsClick: () -> Unit,
|
||||
onExpandClick: () -> Unit,
|
||||
onCollapseClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) =
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
mainAxisSpacing = 4.dp,
|
||||
crossAxisSpacing = 4.dp,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
) {
|
||||
reactionsState.reactions.forEach { reaction ->
|
||||
reactions.forEach { reaction ->
|
||||
MessagesReactionButton(
|
||||
reaction = reaction,
|
||||
onClick = { onReactionClicked(reaction.key) }
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
|
||||
onClick = { onReactionClick(reaction.key) }
|
||||
)
|
||||
}
|
||||
MessagesMoreReactionsButton(
|
||||
onClick = onMoreReactionsClicked
|
||||
when (expandableState) {
|
||||
ExpandableState.Expanded ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = onCollapseClick,
|
||||
)
|
||||
is ExpandableState.Collapsed -> {
|
||||
val hidden = expandableState.hidden
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden)
|
||||
),
|
||||
onClick = onExpandClick,
|
||||
)
|
||||
}
|
||||
ExpandableState.None -> {
|
||||
// No expand or collapse action available
|
||||
}
|
||||
}
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsViewLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsViewDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = aTimelineItemReactions(),
|
||||
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||
onReactionClicked = {},
|
||||
onMoreReactionsClicked = {},
|
||||
fun TimelineItemReactionsViewPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 1).reactions,
|
||||
expandableState = ExpandableState.None,
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 3).reactions,
|
||||
expandableState = ExpandableState.Collapsed(hidden = 7),
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 10).reactions,
|
||||
expandableState = ExpandableState.Expanded,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState
|
||||
) {
|
||||
TimelineItemReactionsView(
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||
onReactionClick = {},
|
||||
onMoreReactionsClick = {},
|
||||
onExpandClick = {},
|
||||
onCollapseClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
<item quantity="one">"%1$d room change"</item>
|
||||
<item quantity="other">"%1$d room changes"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_less_reactions">"Show less"</string>
|
||||
<plurals name="screen_room_timeline_more_reactions">
|
||||
<item quantity="other">"%1$d more"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_add_reaction">"Add emoji"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Camera"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue