Skip to content

Commit 1e013b8

Browse files
committed
Add thread decoration with latest event details
1 parent 0d7d33c commit 1e013b8

File tree

27 files changed

+383
-117
lines changed

27 files changed

+383
-117
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
1616
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
1717
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
1818
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
19+
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
1920
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
2021
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
2122
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@@ -32,7 +33,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId
3233
import io.element.android.libraries.matrix.api.core.UserId
3334
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
3435
import io.element.android.libraries.matrix.api.timeline.Timeline
35-
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
3636
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
3737
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
3838
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@@ -146,7 +146,7 @@ internal fun aTimelineItemEvent(
146146
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
147147
sendState: LocalEventSendState? = null,
148148
inReplyTo: InReplyToDetails? = null,
149-
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
149+
threadInfo: TimelineItemThreadInfo = TimelineItemThreadInfo(threadRootId = null, latestEventText = null, threadSummary = null),
150150
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
151151
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
152152
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
5353
private val BUBBLE_RADIUS = 12.dp
5454
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
5555

56-
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
57-
private const val BUBBLE_WIDTH_RATIO = 0.78f
5856
private val MIN_BUBBLE_WIDTH = 80.dp
5957

6058
@Composable
@@ -66,34 +64,6 @@ fun MessageEventBubble(
6664
modifier: Modifier = Modifier,
6765
content: @Composable BoxScope.() -> Unit = {},
6866
) {
69-
fun bubbleShape(): Shape {
70-
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
71-
return when (state.groupPosition) {
72-
TimelineItemGroupPosition.First -> if (state.isMine) {
73-
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
74-
} else {
75-
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
76-
}
77-
TimelineItemGroupPosition.Middle -> if (state.isMine) {
78-
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
79-
} else {
80-
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
81-
}
82-
TimelineItemGroupPosition.Last -> if (state.isMine) {
83-
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
84-
} else {
85-
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
86-
}
87-
TimelineItemGroupPosition.None ->
88-
RoundedCornerShape(
89-
topLeftCorner,
90-
BUBBLE_RADIUS,
91-
BUBBLE_RADIUS,
92-
BUBBLE_RADIUS
93-
)
94-
}
95-
}
96-
9767
val clickableModifier = if (isTalkbackActive()) {
9868
Modifier
9969
} else {
@@ -108,11 +78,8 @@ fun MessageEventBubble(
10878
}
10979

11080
// Ignore state.isHighlighted for now, we need a design decision on it.
111-
val backgroundBubbleColor = when {
112-
state.isMine -> ElementTheme.colors.messageFromMeBackground
113-
else -> ElementTheme.colors.messageFromOtherBackground
114-
}
115-
val bubbleShape = bubbleShape()
81+
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
82+
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
11683
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
11784
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
11885
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -147,7 +114,7 @@ fun MessageEventBubble(
147114
.testTag(TestTags.messageBubble)
148115
.widthIn(
149116
min = MIN_BUBBLE_WIDTH,
150-
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO)
117+
max = (constraints.maxWidth * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO)
151118
.toInt()
152119
.toDp()
153120
)
@@ -157,6 +124,48 @@ fun MessageEventBubble(
157124
}
158125
}
159126

127+
object MessageEventBubbleDefaults {
128+
fun shape(cutTopStart: Boolean, groupPosition: TimelineItemGroupPosition, isMine: Boolean): Shape {
129+
val topLeftCorner = if (cutTopStart) 0.dp else BUBBLE_RADIUS
130+
return when (groupPosition) {
131+
TimelineItemGroupPosition.First -> if (isMine) {
132+
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
133+
} else {
134+
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
135+
}
136+
TimelineItemGroupPosition.Middle -> if (isMine) {
137+
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
138+
} else {
139+
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
140+
}
141+
TimelineItemGroupPosition.Last -> if (isMine) {
142+
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
143+
} else {
144+
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
145+
}
146+
TimelineItemGroupPosition.None ->
147+
RoundedCornerShape(
148+
topLeftCorner,
149+
BUBBLE_RADIUS,
150+
BUBBLE_RADIUS,
151+
BUBBLE_RADIUS
152+
)
153+
}
154+
}
155+
156+
@Composable
157+
fun backgroundBubbleColor(isMine: Boolean): Color {
158+
return if (isMine) {
159+
ElementTheme.colors.messageFromMeBackground
160+
} else {
161+
ElementTheme.colors.messageFromOtherBackground
162+
}
163+
}
164+
165+
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
166+
const val BUBBLE_WIDTH_RATIO = 0.78f
167+
}
168+
160169
@PreviewsDayNight
161170
@Composable
162171
internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview {

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.draggable
1515
import androidx.compose.foundation.interaction.MutableInteractionSource
1616
import androidx.compose.foundation.layout.Arrangement
1717
import androidx.compose.foundation.layout.Box
18+
import androidx.compose.foundation.layout.BoxWithConstraints
1819
import androidx.compose.foundation.layout.Column
1920
import androidx.compose.foundation.layout.Row
2021
import androidx.compose.foundation.layout.Spacer
@@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
2324
import androidx.compose.foundation.layout.height
2425
import androidx.compose.foundation.layout.padding
2526
import androidx.compose.foundation.layout.size
27+
import androidx.compose.foundation.layout.width
28+
import androidx.compose.foundation.layout.widthIn
2629
import androidx.compose.foundation.layout.wrapContentHeight
2730
import androidx.compose.foundation.shape.CircleShape
2831
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
3437
import androidx.compose.ui.Alignment
3538
import androidx.compose.ui.Modifier
3639
import androidx.compose.ui.draw.clip
40+
import androidx.compose.ui.graphics.graphicsLayer
3741
import androidx.compose.ui.platform.LocalViewConfiguration
3842
import androidx.compose.ui.platform.ViewConfiguration
3943
import androidx.compose.ui.res.pluralStringResource
@@ -43,6 +47,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
4347
import androidx.compose.ui.semantics.isTraversalGroup
4448
import androidx.compose.ui.semantics.semantics
4549
import androidx.compose.ui.semantics.traversalIndex
50+
import androidx.compose.ui.text.style.TextOverflow
4651
import androidx.compose.ui.unit.DpOffset
4752
import androidx.compose.ui.unit.IntOffset
4853
import androidx.compose.ui.unit.dp
@@ -61,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
6166
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
6267
import io.element.android.features.messages.impl.timeline.model.TimelineItem
6368
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
69+
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
6470
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
6571
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
6672
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -78,24 +84,28 @@ import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
7884
import io.element.android.libraries.designsystem.components.EqualWidthColumn
7985
import io.element.android.libraries.designsystem.components.avatar.Avatar
8086
import io.element.android.libraries.designsystem.components.avatar.AvatarData
87+
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
8188
import io.element.android.libraries.designsystem.components.avatar.AvatarType
89+
import io.element.android.libraries.designsystem.modifiers.niceClickable
8290
import io.element.android.libraries.designsystem.preview.ElementPreview
8391
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
8492
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
8593
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
8694
import io.element.android.libraries.designsystem.text.toPx
87-
import io.element.android.libraries.designsystem.theme.components.Button
88-
import io.element.android.libraries.designsystem.theme.components.ButtonSize
8995
import io.element.android.libraries.designsystem.theme.components.Icon
9096
import io.element.android.libraries.designsystem.theme.components.Text
9197
import io.element.android.libraries.matrix.api.core.EventId
9298
import io.element.android.libraries.matrix.api.core.ThreadId
9399
import io.element.android.libraries.matrix.api.core.UserId
94100
import io.element.android.libraries.matrix.api.core.toThreadId
95101
import io.element.android.libraries.matrix.api.timeline.Timeline
102+
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
96103
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
97104
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
105+
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
106+
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
98107
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
108+
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
99109
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
100110
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
101111
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -258,18 +268,20 @@ fun TimelineItemEventRow(
258268

259269
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) {
260270
event.threadInfo.threadSummary?.let { threadSummary ->
261-
val threadPart = stringResource(CommonStrings.common_thread)
262-
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies ->
263-
pluralStringResource(CommonPlurals.common_replies, replies, replies)
264-
}
265-
Button(
266-
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp)
267-
.align(if (event.isMine) Alignment.End else Alignment.Start),
268-
text = "$threadPart - $numberOfReplies",
269-
size = ButtonSize.Small,
271+
ThreadSummaryView(
272+
modifier = if (event.isMine) {
273+
Modifier.align(Alignment.End).padding(end = 16.dp)
274+
} else {
275+
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
276+
}.padding(top = 2.dp),
277+
threadSummary = threadSummary,
278+
latestEventText = event.threadInfo.latestEventText,
279+
isOutgoing = event.isMine,
270280
onClick = {
271-
eventSink(TimelineEvents.OpenThread(event.eventId!!.toThreadId(), null))
272-
},
281+
event.eventId?.let {
282+
eventSink(TimelineEvents.OpenThread(it.toThreadId(), null))
283+
}
284+
}
273285
)
274286
}
275287
}
@@ -288,6 +300,79 @@ fun TimelineItemEventRow(
288300
}
289301
}
290302

303+
@Composable
304+
private fun ThreadSummaryView(
305+
threadSummary: ThreadSummary,
306+
latestEventText: String?,
307+
isOutgoing: Boolean,
308+
onClick: () -> Unit,
309+
modifier: Modifier = Modifier,
310+
) {
311+
val bubbleShape = MessageEventBubbleDefaults.shape(false, TimelineItemGroupPosition.None, isOutgoing)
312+
BoxWithConstraints(modifier = modifier) {
313+
Row(
314+
modifier = Modifier
315+
.then(if (!isOutgoing) Modifier.padding(start = 16.dp) else Modifier)
316+
.graphicsLayer {
317+
shape = bubbleShape
318+
clip = true
319+
}
320+
.background(MessageEventBubbleDefaults.backgroundBubbleColor(isOutgoing))
321+
.niceClickable(onClick)
322+
.padding(horizontal = 12.dp, vertical = 10.dp)
323+
.widthIn(max = (maxWidth - 24.dp) * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO),
324+
verticalAlignment = Alignment.CenterVertically,
325+
) {
326+
Icon(
327+
imageVector = CompoundIcons.ThreadsSolid(),
328+
contentDescription = null,
329+
tint = ElementTheme.colors.iconSecondary,
330+
)
331+
Spacer(modifier = Modifier.width(4.dp))
332+
Text(
333+
text = pluralStringResource(CommonPlurals.common_replies, threadSummary.numberOfReplies.toInt(), threadSummary.numberOfReplies),
334+
style = ElementTheme.typography.fontBodySmMedium,
335+
color = ElementTheme.colors.textSecondary,
336+
)
337+
338+
Spacer(modifier = Modifier.width(8.dp))
339+
340+
threadSummary.latestEvent.dataOrNull()?.let { latestEvent ->
341+
val avatarData = AvatarData(
342+
id = latestEvent.senderId.value,
343+
name = latestEvent.senderProfile.getDisplayName(),
344+
url = latestEvent.senderProfile.getAvatarUrl(),
345+
size = AvatarSize.TimelineThreadLatestEventSender,
346+
)
347+
Avatar(
348+
avatarData = avatarData,
349+
avatarType = AvatarType.User,
350+
)
351+
352+
Spacer(modifier = Modifier.width(8.dp))
353+
354+
Text(
355+
text = latestEvent.senderProfile.getDisplayName() ?: latestEvent.senderId.value,
356+
style = ElementTheme.typography.fontBodySmMedium,
357+
color = ElementTheme.colors.textSecondary,
358+
)
359+
360+
Spacer(modifier = Modifier.width(4.dp))
361+
362+
latestEventText?.let {
363+
Text(
364+
text = it,
365+
style = ElementTheme.typography.fontBodySmRegular,
366+
color = ElementTheme.colors.textSecondary,
367+
overflow = TextOverflow.Ellipsis,
368+
maxLines = 1,
369+
)
370+
}
371+
}
372+
}
373+
}
374+
}
375+
291376
/**
292377
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
293378
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
@@ -746,13 +831,69 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
746831
" hopefully can be manually adjusted to test different behaviors."
747832
),
748833
groupPosition = TimelineItemGroupPosition.First,
749-
threadInfo = EventThreadInfo(
834+
threadInfo = TimelineItemThreadInfo(
750835
threadRootId = ThreadId("\$thread-root-id"),
751-
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L)
836+
latestEventText = "This is the latest message in the thread",
837+
threadSummary = ThreadSummary(AsyncData.Success(
838+
EmbeddedEventInfo(
839+
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
840+
content = MessageContent(
841+
body = "This is the latest message in the thread",
842+
inReplyTo = null,
843+
isEdited = false,
844+
threadInfo = EventThreadInfo(null, null),
845+
type = TextMessageType("This is the latest message in the thread", null)
846+
),
847+
senderId = UserId("@user:id"),
848+
senderProfile = ProfileTimelineDetails.Ready(
849+
displayName = "Alice",
850+
avatarUrl = null,
851+
displayNameAmbiguous = true,
852+
),
853+
timestamp = 0L,
854+
)
855+
), numberOfReplies = 20L)
752856
)
753857
),
754858
displayThreadSummaries = true,
755859
)
756860
}
757861
}
758862
}
863+
864+
@PreviewsDayNight
865+
@Composable
866+
internal fun ThreadSummaryViewPreview() {
867+
ElementPreview {
868+
val body = "This is the latest message in the thread"
869+
val threadSummary = ThreadSummary(
870+
AsyncData.Success(
871+
EmbeddedEventInfo(
872+
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
873+
content = MessageContent(
874+
body = body,
875+
inReplyTo = null,
876+
isEdited = false,
877+
threadInfo = EventThreadInfo(null, null),
878+
type = TextMessageType(body, null)
879+
),
880+
senderId = UserId("@user:id"),
881+
senderProfile = ProfileTimelineDetails.Ready(
882+
displayName = "Alice",
883+
avatarUrl = null,
884+
displayNameAmbiguous = true,
885+
),
886+
timestamp = 0L,
887+
)
888+
),
889+
numberOfReplies = 12,
890+
)
891+
892+
ThreadSummaryView(
893+
threadSummary = threadSummary,
894+
latestEventText = "Some event with a very long text that should get clipped",
895+
isOutgoing = true,
896+
onClick = {},
897+
)
898+
}
899+
}

0 commit comments

Comments
 (0)