@@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.draggable
15
15
import androidx.compose.foundation.interaction.MutableInteractionSource
16
16
import androidx.compose.foundation.layout.Arrangement
17
17
import androidx.compose.foundation.layout.Box
18
+ import androidx.compose.foundation.layout.BoxWithConstraints
18
19
import androidx.compose.foundation.layout.Column
19
20
import androidx.compose.foundation.layout.Row
20
21
import androidx.compose.foundation.layout.Spacer
@@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
23
24
import androidx.compose.foundation.layout.height
24
25
import androidx.compose.foundation.layout.padding
25
26
import androidx.compose.foundation.layout.size
27
+ import androidx.compose.foundation.layout.width
28
+ import androidx.compose.foundation.layout.widthIn
26
29
import androidx.compose.foundation.layout.wrapContentHeight
27
30
import androidx.compose.foundation.shape.CircleShape
28
31
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
34
37
import androidx.compose.ui.Alignment
35
38
import androidx.compose.ui.Modifier
36
39
import androidx.compose.ui.draw.clip
40
+ import androidx.compose.ui.graphics.graphicsLayer
37
41
import androidx.compose.ui.platform.LocalViewConfiguration
38
42
import androidx.compose.ui.platform.ViewConfiguration
39
43
import androidx.compose.ui.res.pluralStringResource
@@ -43,6 +47,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
43
47
import androidx.compose.ui.semantics.isTraversalGroup
44
48
import androidx.compose.ui.semantics.semantics
45
49
import androidx.compose.ui.semantics.traversalIndex
50
+ import androidx.compose.ui.text.style.TextOverflow
46
51
import androidx.compose.ui.unit.DpOffset
47
52
import androidx.compose.ui.unit.IntOffset
48
53
import androidx.compose.ui.unit.dp
@@ -61,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
61
66
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
62
67
import io.element.android.features.messages.impl.timeline.model.TimelineItem
63
68
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
69
+ import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
64
70
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
65
71
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
66
72
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -78,24 +84,28 @@ import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
78
84
import io.element.android.libraries.designsystem.components.EqualWidthColumn
79
85
import io.element.android.libraries.designsystem.components.avatar.Avatar
80
86
import io.element.android.libraries.designsystem.components.avatar.AvatarData
87
+ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
81
88
import io.element.android.libraries.designsystem.components.avatar.AvatarType
89
+ import io.element.android.libraries.designsystem.modifiers.niceClickable
82
90
import io.element.android.libraries.designsystem.preview.ElementPreview
83
91
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
84
92
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
85
93
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
86
94
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
89
95
import io.element.android.libraries.designsystem.theme.components.Icon
90
96
import io.element.android.libraries.designsystem.theme.components.Text
91
97
import io.element.android.libraries.matrix.api.core.EventId
92
98
import io.element.android.libraries.matrix.api.core.ThreadId
93
99
import io.element.android.libraries.matrix.api.core.UserId
94
100
import io.element.android.libraries.matrix.api.core.toThreadId
95
101
import io.element.android.libraries.matrix.api.timeline.Timeline
102
+ import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
96
103
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
97
104
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
98
107
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
108
+ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
99
109
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
100
110
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
101
111
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -258,18 +268,20 @@ fun TimelineItemEventRow(
258
268
259
269
if (displayThreadSummaries && timelineMode !is Timeline .Mode .Thread ) {
260
270
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 ,
270
280
onClick = {
271
- eventSink(TimelineEvents .OpenThread (event.eventId!! .toThreadId(), null ))
272
- },
281
+ event.eventId?.let {
282
+ eventSink(TimelineEvents .OpenThread (it.toThreadId(), null ))
283
+ }
284
+ }
273
285
)
274
286
}
275
287
}
@@ -288,6 +300,79 @@ fun TimelineItemEventRow(
288
300
}
289
301
}
290
302
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
+
291
376
/* *
292
377
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
293
378
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
@@ -746,13 +831,69 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
746
831
" hopefully can be manually adjusted to test different behaviors."
747
832
),
748
833
groupPosition = TimelineItemGroupPosition .First ,
749
- threadInfo = EventThreadInfo (
834
+ threadInfo = TimelineItemThreadInfo (
750
835
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 )
752
856
)
753
857
),
754
858
displayThreadSummaries = true ,
755
859
)
756
860
}
757
861
}
758
862
}
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