Skip to content

Commit 2397307

Browse files
authored
Merge pull request #1298 from vector-im/feature/fga/timeline_thread_decoration
Feature/fga/timeline thread decoration
2 parents e9b4b91 + 4e5036f commit 2397307

File tree

32 files changed

+178
-69
lines changed

32 files changed

+178
-69
lines changed

changelog.d/1236.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Display a thread decorator in timeline so we know when a message is coming from a thread.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ class MessagesPresenter @AssistedInject constructor(
209209
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
210210
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
211211
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
212-
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
212+
TimelineItemAction.Reply,
213+
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState)
213214
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
214215
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
215216
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
@@ -312,6 +313,7 @@ class MessagesPresenter @AssistedInject constructor(
312313
is TimelineItemUnknownContent -> null
313314
}
314315
val composerMode = MessageComposerMode.Reply(
316+
isThreaded = targetEvent.isThreaded,
315317
senderName = targetEvent.safeSenderName,
316318
eventId = targetEvent.eventId,
317319
attachmentThumbnailInfo = attachmentThumbnailInfo,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ class ActionListPresenter @Inject constructor(
130130
if (timelineItem.isRemote) {
131131
// Can only reply or forward messages already uploaded to the server
132132
if (userCanSendMessage) {
133-
add(TimelineItemAction.Reply)
133+
if (timelineItem.isThreaded) {
134+
add(TimelineItemAction.ReplyInThread)
135+
} else {
136+
add(TimelineItemAction.Reply)
137+
}
134138
}
135139
add(TimelineItemAction.Forward)
136140
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ sealed class TimelineItemAction(
3232
data object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
3333
data object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
3434
data object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
35+
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, VectorIcons.Reply)
3536
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
3637
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
3738
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ internal fun aTimelineItemEvent(
111111
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
112112
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
113113
inReplyTo: InReplyTo? = null,
114+
isThreaded: Boolean = false,
114115
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
115116
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
116117
): TimelineItem.Event {
@@ -129,6 +130,7 @@ internal fun aTimelineItemEvent(
129130
localSendState = sendState,
130131
inReplyTo = inReplyTo,
131132
debugInfo = debugInfo,
133+
isThreaded = isThreaded,
132134
origin = null
133135
)
134136
}

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

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.Orientation
2424
import androidx.compose.foundation.gestures.draggable
2525
import androidx.compose.foundation.interaction.MutableInteractionSource
2626
import androidx.compose.foundation.layout.Arrangement
27+
import androidx.compose.foundation.layout.Arrangement.spacedBy
2728
import androidx.compose.foundation.layout.Box
2829
import androidx.compose.foundation.layout.Column
2930
import androidx.compose.foundation.layout.PaddingValues
@@ -75,6 +76,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
7576
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
7677
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
7778
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
79+
import io.element.android.libraries.designsystem.VectorIcons
7880
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
7981
import io.element.android.libraries.designsystem.components.EqualWidthColumn
8082
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -84,6 +86,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
8486
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
8587
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
8688
import io.element.android.libraries.designsystem.text.toPx
89+
import io.element.android.libraries.designsystem.theme.components.Icon
8790
import io.element.android.libraries.designsystem.theme.components.Text
8891
import io.element.android.libraries.matrix.api.core.EventId
8992
import io.element.android.libraries.matrix.api.core.UserId
@@ -370,14 +373,6 @@ private fun MessageEventBubbleContent(
370373
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
371374
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
372375
) {
373-
val timestampPosition = when (event.content) {
374-
is TimelineItemImageContent,
375-
is TimelineItemVideoContent,
376-
is TimelineItemLocationContent -> TimestampPosition.Overlay
377-
is TimelineItemPollContent -> TimestampPosition.Below
378-
else -> TimestampPosition.Default
379-
}
380-
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
381376

382377
// Long clicks are not not automatically propagated from a `clickable`
383378
// to its `combinedClickable` parent so we do it manually
@@ -398,6 +393,24 @@ private fun MessageEventBubbleContent(
398393
)
399394
}
400395

396+
@Composable
397+
fun ThreadDecoration(
398+
modifier: Modifier = Modifier
399+
) {
400+
Row(
401+
modifier = modifier,
402+
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
403+
verticalAlignment = Alignment.CenterVertically,
404+
) {
405+
Icon(resourceId = VectorIcons.ThreadDecoration, contentDescription = null, tint = ElementTheme.colors.iconSecondary)
406+
Text(
407+
text = stringResource(CommonStrings.common_thread),
408+
style = ElementTheme.typography.fontBodyXsRegular,
409+
color = ElementTheme.colors.textPrimary,
410+
)
411+
}
412+
}
413+
401414
@Composable
402415
fun ContentAndTimestampView(
403416
timestampPosition: TimestampPosition,
@@ -450,47 +463,74 @@ private fun MessageEventBubbleContent(
450463
/** Groups the different components in a Column with some space between them. */
451464
@Composable
452465
fun CommonLayout(
466+
timestampPosition: TimestampPosition,
467+
showThreadDecoration: Boolean,
453468
inReplyToDetails: InReplyTo.Ready?,
454469
modifier: Modifier = Modifier
455470
) {
456-
var modifierWithPadding: Modifier = Modifier
457-
var contentModifier: Modifier = Modifier
458-
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
459-
when {
460-
inReplyToDetails != null -> {
461-
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
462-
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
463-
val text = textForInReplyTo(inReplyToDetails)
464-
ReplyToContent(
465-
senderName = senderName,
466-
text = text,
467-
attachmentThumbnailInfo = attachmentThumbnailInfo,
468-
modifier = Modifier
469-
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
470-
.clip(RoundedCornerShape(6.dp))
471-
.clickable(enabled = true, onClick = inReplyToClick),
472-
)
473-
if (timestampPosition == TimestampPosition.Overlay) {
474-
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
475-
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
476-
} else {
477-
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
478-
}
479-
}
480-
timestampPosition != TimestampPosition.Overlay -> {
481-
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
471+
val modifierWithPadding: Modifier
472+
val contentModifier: Modifier
473+
when {
474+
inReplyToDetails != null -> {
475+
if (timestampPosition == TimestampPosition.Overlay) {
476+
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
477+
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
478+
} else {
479+
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
480+
modifierWithPadding = Modifier
482481
}
483482
}
483+
timestampPosition != TimestampPosition.Overlay -> {
484+
modifierWithPadding = Modifier
485+
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
486+
}
487+
else -> {
488+
modifierWithPadding = Modifier
489+
contentModifier = Modifier
490+
}
491+
}
484492

493+
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
494+
if (showThreadDecoration) {
495+
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
496+
}
497+
if (inReplyToDetails != null) {
498+
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
499+
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
500+
val text = textForInReplyTo(inReplyToDetails)
501+
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
502+
ReplyToContent(
503+
senderName = senderName,
504+
text = text,
505+
attachmentThumbnailInfo = attachmentThumbnailInfo,
506+
modifier = Modifier
507+
.padding(top = topPadding, start = 8.dp, end = 8.dp)
508+
.clip(RoundedCornerShape(6.dp))
509+
.clickable(enabled = true, onClick = inReplyToClick),
510+
)
511+
}
485512
ContentAndTimestampView(
486513
timestampPosition = timestampPosition,
487-
contentModifier = contentModifier,
488514
modifier = modifierWithPadding,
515+
contentModifier = contentModifier,
489516
)
490517
}
491518
}
492519

493-
CommonLayout(inReplyToDetails = replyToDetails, modifier = bubbleModifier)
520+
val timestampPosition = when (event.content) {
521+
is TimelineItemImageContent,
522+
is TimelineItemVideoContent,
523+
is TimelineItemLocationContent -> TimestampPosition.Overlay
524+
is TimelineItemPollContent -> TimestampPosition.Below
525+
else -> TimestampPosition.Default
526+
}
527+
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
528+
CommonLayout(
529+
showThreadDecoration = event.isThreaded,
530+
timestampPosition = timestampPosition,
531+
inReplyToDetails = replyToDetails,
532+
modifier = bubbleModifier
533+
)
494534
}
495535

496536
@Composable
@@ -694,6 +734,7 @@ private fun ContentToPreviewWithReply() {
694734
aspectRatio = 5f
695735
),
696736
inReplyTo = aInReplyToReady(replyContent),
737+
isThreaded = true,
697738
groupPosition = TimelineItemGroupPosition.Last,
698739
),
699740
isHighlighted = false,
@@ -714,11 +755,11 @@ private fun ContentToPreviewWithReply() {
714755
}
715756

716757
private fun aInReplyToReady(
717-
replyContent: String
758+
replyContent: String,
718759
): InReplyTo.Ready {
719760
return InReplyTo.Ready(
720761
eventId = EventId("\$event"),
721-
content = MessageContent(replyContent, null, false, TextMessageType(replyContent, null)),
762+
content = MessageContent(replyContent, null, false, false, TextMessageType(replyContent, null)),
722763
senderId = UserId("@Sender:domain"),
723764
senderDisplayName = "Sender",
724765
senderAvatarUrl = null,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
7171
url = senderAvatarUrl,
7272
size = AvatarSize.TimelineSender
7373
)
74+
currentTimelineItem.event
7475
return TimelineItem.Event(
7576
id = currentTimelineItem.uniqueId.toString(),
7677
eventId = currentTimelineItem.eventId,
@@ -85,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
8586
reactionsState = currentTimelineItem.computeReactionsState(),
8687
localSendState = currentTimelineItem.event.localSendState,
8788
inReplyTo = currentTimelineItem.event.inReplyTo(),
89+
isThreaded = currentTimelineItem.event.isThreaded(),
8890
debugInfo = currentTimelineItem.event.debugInfo,
8991
origin = currentTimelineItem.event.origin,
9092
)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ sealed interface TimelineItem {
6666
val reactionsState: TimelineItemReactions,
6767
val localSendState: LocalEventSendState?,
6868
val inReplyTo: InReplyTo?,
69+
val isThreaded: Boolean,
6970
val debugInfo: TimelineItemDebugInfo,
7071
val origin: TimelineItemEventOrigin?,
7172
) : TimelineItem {

features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ internal fun aMessageEvent(
3737
isMine: Boolean = true,
3838
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
3939
inReplyTo: InReplyTo? = null,
40+
isThreaded: Boolean = false,
4041
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
4142
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
4243
) = TimelineItem.Event(
@@ -52,5 +53,6 @@ internal fun aMessageEvent(
5253
localSendState = sendState,
5354
inReplyTo = inReplyTo,
5455
debugInfo = debugInfo,
56+
isThreaded = isThreaded,
5557
origin = null
5658
)

features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ fun anEditMode(
632632
transactionId: TransactionId? = null,
633633
) = MessageComposerMode.Edit(eventId, message, transactionId)
634634

635-
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
635+
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
636636
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
637637

638638
private fun String.toMessage() = Message(

0 commit comments

Comments
 (0)