Merge pull request #3500 from element-hq/feature/fga/pinned_message_icon
Pinned messages : add pin icon in timeline for pinned events.
This commit is contained in:
@@ -82,6 +82,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
userHasPermissionToSendMessage = false,
|
||||
userHasPermissionToSendReaction = false,
|
||||
isCallOngoing = false,
|
||||
// don't compute this value or the pin icon will be shown
|
||||
pinnedEventIds = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||
isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
|
||||
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,5 @@ data class TimelineRoomInfo(
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToSendReaction: Boolean,
|
||||
val isCallOngoing: Boolean,
|
||||
val pinnedEventIds: List<EventId>
|
||||
)
|
||||
|
||||
@@ -240,10 +240,12 @@ internal fun aTimelineRoomInfo(
|
||||
name: String = "Room name",
|
||||
isDm: Boolean = false,
|
||||
userHasPermissionToSendMessage: Boolean = true,
|
||||
pinnedEventIds: List<EventId> = emptyList(),
|
||||
) = TimelineRoomInfo(
|
||||
isDm = isDm,
|
||||
name = name,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = true,
|
||||
isCallOngoing = false,
|
||||
pinnedEventIds = pinnedEventIds,
|
||||
)
|
||||
|
||||
@@ -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<TimelineItem.Event>()
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user