Pinned messages : add pin icon in timeline for pinned events.

This commit is contained in:
ganfra 2024-09-19 20:11:38 +02:00
parent 6111020cf0
commit 2b796b2dda
3 changed files with 53 additions and 25 deletions

View file

@ -128,8 +128,8 @@ fun TimelineView(
Box(modifier) { Box(modifier) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
@ -269,8 +269,8 @@ private fun BoxScope.TimelineScrollHelper(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() }, onClick = { jumpToBottom() },
) )
} }
@ -297,8 +297,8 @@ private fun JumpToBottomButton(
) { ) {
Icon( Icon(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.rotate(90f), .rotate(90f),
imageVector = CompoundIcons.ArrowRight(), imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
) )
@ -312,12 +312,18 @@ internal fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreview { ) = ElementPreview {
val timelineItems = aTimelineItemList(content) val timelineItems = aTimelineItemList(content)
val timelineEvents = timelineItems.filterIsInstance<TimelineItem.Event>()
val lastEventIdFromMe = timelineEvents.firstOrNull { it.isMine }?.eventId
val lastEventIdFromOther = timelineEvents.firstOrNull { !it.isMine }?.eventId
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) { ) {
TimelineView( TimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineRoomInfo = aTimelineRoomInfo(
pinnedEventIds = listOfNotNull(lastEventIdFromMe, lastEventIdFromOther)
),
focusedEventIndex = 0, focusedEventIndex = 0,
), ),
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),

View file

@ -11,8 +11,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@ -40,6 +39,7 @@ import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -49,11 +49,11 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
internal val BUBBLE_INCOMING_OFFSET = 16.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val avatarRadius = AvatarSize.TimelineSender.dp / 2
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now. // Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -93,14 +93,6 @@ fun MessageEventBubble(
} }
} }
fun Modifier.offsetForItem(): Modifier {
return when {
state.isMine -> this
state.timelineRoomInfo.isDm -> this
else -> offset(x = BUBBLE_INCOMING_OFFSET)
}
}
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when { val backgroundBubbleColor = when {
state.isMine -> ElementTheme.colors.messageFromMeBackground state.isMine -> ElementTheme.colors.messageFromMeBackground
@ -109,11 +101,8 @@ fun MessageEventBubble(
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
Box( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxWidth(BUBBLE_WIDTH_RATIO)
.padding(start = avatarRadius, end = 16.dp)
.offsetForItem()
.graphicsLayer { .graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
@ -138,7 +127,10 @@ fun MessageEventBubble(
Surface( Surface(
modifier = Modifier modifier = Modifier
.testTag(TestTags.messageBubble) .testTag(TestTags.messageBubble)
.widthIn(min = 80.dp) .widthIn(
min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO).toInt().toDp()
)
.clip(bubbleShape) .clip(bubbleShape)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -100,6 +101,8 @@ val NEGATIVE_MARGIN_FOR_BUBBLE = (-8).dp
// Width of the transparent border around the sender avatar // Width of the transparent border around the sender avatar
val SENDER_AVATAR_BORDER_WIDTH = 3.dp val SENDER_AVATAR_BORDER_WIDTH = 3.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -277,6 +280,7 @@ private fun TimelineItemEventRowContent(
sender, sender,
message, message,
reactions, reactions,
pinIcon,
) = createRefs() ) = createRefs()
// Sender // Sender
@ -311,7 +315,12 @@ private fun TimelineItemEventRowContent(
modifier = Modifier modifier = Modifier
.constrainAs(message) { .constrainAs(message) {
top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE) top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
this.linkStartOrEnd(event) if (event.isMine) {
end.linkTo(parent.end, margin = 16.dp)
} else {
val startMargin = if (timelineRoomInfo.isDm) 16.dp else 16.dp + BUBBLE_INCOMING_OFFSET
start.linkTo(parent.start, margin = startMargin)
}
}, },
state = bubbleState, state = bubbleState,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -327,6 +336,27 @@ private fun TimelineItemEventRowContent(
) )
} }
// Pin icon
val isEventPinned = timelineRoomInfo.pinnedEventIds.contains(event.eventId)
if (isEventPinned) {
Icon(
imageVector = CompoundIcons.PinSolid(),
contentDescription = stringResource(CommonStrings.common_pinned),
tint = ElementTheme.colors.iconTertiary,
modifier = Modifier
.padding(1.dp)
.size(16.dp)
.constrainAs(pinIcon) {
top.linkTo(message.top)
if (event.isMine) {
end.linkTo(message.start, margin = 8.dp)
} else {
start.linkTo(message.end, margin = 8.dp)
}
}
)
}
// Reactions // Reactions
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView( TimelineItemReactionsView(