diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 17ce5264fc..83ede60323 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -128,8 +128,8 @@ fun TimelineView( Box(modifier) { LazyColumn( modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .fillMaxSize() + .nestedScroll(nestedScrollConnection), state = lazyListState, reverseLayout = useReverseLayout, 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 isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 12.dp), + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 12.dp), onClick = { jumpToBottom() }, ) } @@ -297,8 +297,8 @@ private fun JumpToBottomButton( ) { Icon( modifier = Modifier - .size(24.dp) - .rotate(90f), + .size(24.dp) + .rotate(90f), imageVector = CompoundIcons.ArrowRight(), contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) ) @@ -312,12 +312,18 @@ internal fun TimelineViewPreview( @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent ) = ElementPreview { val timelineItems = aTimelineItemList(content) + val timelineEvents = timelineItems.filterIsInstance() + val lastEventIdFromMe = timelineEvents.firstOrNull { it.isMine }?.eventId + val lastEventIdFromOther = timelineEvents.firstOrNull { !it.isMine }?.eventId CompositionLocalProvider( LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), ) { TimelineView( state = aTimelineState( timelineItems = timelineItems, + timelineRoomInfo = aTimelineRoomInfo( + pinnedEventIds = listOfNotNull(lastEventIdFromMe, lastEventIdFromOther) + ), focusedEventIndex = 0, ), typingNotificationState = aTypingNotificationState(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 5090ac118a..dde26659a8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -11,8 +11,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.preview.ElementPreview 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.theme.components.Surface 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 private val BUBBLE_RADIUS = 12.dp -internal val BUBBLE_INCOMING_OFFSET = 16.dp 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. -private const val BUBBLE_WIDTH_RATIO = 0.85f +// 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.78f +private val MIN_BUBBLE_WIDTH = 80.dp @OptIn(ExperimentalFoundationApi::class) @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. val backgroundBubbleColor = when { state.isMine -> ElementTheme.colors.messageFromMeBackground @@ -109,11 +101,8 @@ fun MessageEventBubble( val bubbleShape = bubbleShape() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() - Box( + BoxWithConstraints( modifier = modifier - .fillMaxWidth(BUBBLE_WIDTH_RATIO) - .padding(start = avatarRadius, end = 16.dp) - .offsetForItem() .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } @@ -138,7 +127,10 @@ fun MessageEventBubble( Surface( modifier = Modifier .testTag(TestTags.messageBubble) - .widthIn(min = 80.dp) + .widthIn( + min = MIN_BUBBLE_WIDTH, + max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO).toInt().toDp() + ) .clip(bubbleShape) .combinedClickable( onClick = onClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index e068eb5522..53547cbdae 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxWidth 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.layout.wrapContentHeight 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 val SENDER_AVATAR_BORDER_WIDTH = 3.dp +private val BUBBLE_INCOMING_OFFSET = 16.dp + @Composable fun TimelineItemEventRow( event: TimelineItem.Event, @@ -277,6 +280,7 @@ private fun TimelineItemEventRowContent( sender, message, reactions, + pinIcon, ) = createRefs() // Sender @@ -311,7 +315,12 @@ private fun TimelineItemEventRowContent( modifier = Modifier .constrainAs(message) { 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, 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 if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactionsView(