Merge pull request #1298 from vector-im/feature/fga/timeline_thread_decoration

Feature/fga/timeline thread decoration
This commit is contained in:
ganfra
2023-09-14 18:45:33 +02:00
committed by GitHub
32 changed files with 178 additions and 69 deletions

1
changelog.d/1236.feature Normal file
View File

@@ -0,0 +1 @@
Display a thread decorator in timeline so we know when a message is coming from a thread.

View File

@@ -209,7 +209,8 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
@@ -312,6 +313,7 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemUnknownContent -> null
}
val composerMode = MessageComposerMode.Reply(
isThreaded = targetEvent.isThreaded,
senderName = targetEvent.safeSenderName,
eventId = targetEvent.eventId,
attachmentThumbnailInfo = attachmentThumbnailInfo,

View File

@@ -130,7 +130,11 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
if (userCanSendMessage) {
add(TimelineItemAction.Reply)
if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread)
} else {
add(TimelineItemAction.Reply)
}
}
add(TimelineItemAction.Forward)
}

View File

@@ -32,6 +32,7 @@ sealed class TimelineItemAction(
data object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, VectorIcons.Reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)

View File

@@ -111,6 +111,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
): TimelineItem.Event {
@@ -129,6 +130,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
origin = null
)
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -75,6 +76,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -84,6 +86,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@@ -370,14 +373,6 @@ private fun MessageEventBubbleContent(
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
) {
val timestampPosition = when (event.content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
// Long clicks are not not automatically propagated from a `clickable`
// to its `combinedClickable` parent so we do it manually
@@ -398,6 +393,24 @@ private fun MessageEventBubbleContent(
)
}
@Composable
fun ThreadDecoration(
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(resourceId = VectorIcons.ThreadDecoration, contentDescription = null, tint = ElementTheme.colors.iconSecondary)
Text(
text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
@Composable
fun ContentAndTimestampView(
timestampPosition: TimestampPosition,
@@ -450,47 +463,74 @@ private fun MessageEventBubbleContent(
/** Groups the different components in a Column with some space between them. */
@Composable
fun CommonLayout(
timestampPosition: TimestampPosition,
showThreadDecoration: Boolean,
inReplyToDetails: InReplyTo.Ready?,
modifier: Modifier = Modifier
) {
var modifierWithPadding: Modifier = Modifier
var contentModifier: Modifier = Modifier
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
when {
inReplyToDetails != null -> {
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
val text = textForInReplyTo(inReplyToDetails)
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
)
if (timestampPosition == TimestampPosition.Overlay) {
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
} else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
}
}
timestampPosition != TimestampPosition.Overlay -> {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
val modifierWithPadding: Modifier
val contentModifier: Modifier
when {
inReplyToDetails != null -> {
if (timestampPosition == TimestampPosition.Overlay) {
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
} else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
modifierWithPadding = Modifier
}
}
timestampPosition != TimestampPosition.Overlay -> {
modifierWithPadding = Modifier
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
else -> {
modifierWithPadding = Modifier
contentModifier = Modifier
}
}
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
if (showThreadDecoration) {
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
}
if (inReplyToDetails != null) {
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
val text = textForInReplyTo(inReplyToDetails)
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
)
}
ContentAndTimestampView(
timestampPosition = timestampPosition,
contentModifier = contentModifier,
modifier = modifierWithPadding,
contentModifier = contentModifier,
)
}
}
CommonLayout(inReplyToDetails = replyToDetails, modifier = bubbleModifier)
val timestampPosition = when (event.content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
CommonLayout(
showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition,
inReplyToDetails = replyToDetails,
modifier = bubbleModifier
)
}
@Composable
@@ -694,6 +734,7 @@ private fun ContentToPreviewWithReply() {
aspectRatio = 5f
),
inReplyTo = aInReplyToReady(replyContent),
isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
isHighlighted = false,
@@ -714,11 +755,11 @@ private fun ContentToPreviewWithReply() {
}
private fun aInReplyToReady(
replyContent: String
replyContent: String,
): InReplyTo.Ready {
return InReplyTo.Ready(
eventId = EventId("\$event"),
content = MessageContent(replyContent, null, false, TextMessageType(replyContent, null)),
content = MessageContent(replyContent, null, false, false, TextMessageType(replyContent, null)),
senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender",
senderAvatarUrl = null,

View File

@@ -71,6 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
url = senderAvatarUrl,
size = AvatarSize.TimelineSender
)
currentTimelineItem.event
return TimelineItem.Event(
id = currentTimelineItem.uniqueId.toString(),
eventId = currentTimelineItem.eventId,
@@ -85,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
)

View File

@@ -66,6 +66,7 @@ sealed interface TimelineItem {
val reactionsState: TimelineItemReactions,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem {

View File

@@ -37,6 +37,7 @@ internal fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event(
@@ -52,5 +53,6 @@ internal fun aMessageEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
origin = null
)

View File

@@ -632,7 +632,7 @@ fun anEditMode(
transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
private fun String.toMessage() = Message(

View File

@@ -44,6 +44,7 @@ class TimelineItemGrouperTest {
reactionsState = aTimelineItemReactions(count = 0),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
isThreaded = false,
debugInfo = aTimelineItemDebugInfo(),
origin = null
)

View File

@@ -41,4 +41,5 @@ object VectorIcons {
val Quote = R.drawable.ic_quote
val Strikethrough = R.drawable.ic_strikethrough
val Underline = R.drawable.ic_underline
val ThreadDecoration = R.drawable.ic_thread_decoration
}

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="13dp"
android:viewportWidth="14"
android:viewportHeight="13">
<path
android:pathData="M3.667,4.667C3.478,4.667 3.319,4.603 3.192,4.475C3.064,4.347 3,4.189 3,4C3,3.811 3.064,3.653 3.192,3.525C3.319,3.397 3.478,3.333 3.667,3.333H10.333C10.522,3.333 10.681,3.397 10.808,3.525C10.936,3.653 11,3.811 11,4C11,4.189 10.936,4.347 10.808,4.475C10.681,4.603 10.522,4.667 10.333,4.667H3.667Z"
android:fillColor="#656D77"/>
<path
android:pathData="M3.667,7.333C3.478,7.333 3.319,7.269 3.192,7.142C3.064,7.014 3,6.856 3,6.667C3,6.478 3.064,6.319 3.192,6.192C3.319,6.064 3.478,6 3.667,6H7.667C7.855,6 8.014,6.064 8.142,6.192C8.269,6.319 8.333,6.478 8.333,6.667C8.333,6.856 8.269,7.014 8.142,7.142C8.014,7.269 7.855,7.333 7.667,7.333H3.667Z"
android:fillColor="#656D77"/>
<path
android:pathData="M1.471,12.195C1.051,12.615 0.333,12.318 0.333,11.724V1.333C0.333,0.597 0.93,0 1.667,0H12.333C13.07,0 13.667,0.597 13.667,1.333V9.333C13.667,10.07 13.07,10.667 12.333,10.667H3L1.471,12.195ZM3,9.333H12.333V1.333H1.667V10.114L2.057,9.724C2.307,9.474 2.646,9.333 3,9.333Z"
android:fillColor="#656D77"/>
</vector>

View File

@@ -153,7 +153,7 @@ class DefaultRoomLastMessageFormatterTests {
fun `Message contents`() {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, type)
return MessageContent(body, null, false, false,type)
}
val sharedContentMessagesTypes = arrayOf(

View File

@@ -32,6 +32,7 @@ data class MessageContent(
val body: String,
val inReplyTo: InReplyTo?,
val isEdited: Boolean,
val isThreaded: Boolean,
val type: MessageType?
) : EventContent

View File

@@ -40,6 +40,11 @@ data class EventTimelineItem(
fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo
}
fun isThreaded(): Boolean {
return (content as? MessageContent)?.isThreaded ?: false
}
fun hasNotLoadedInReplyTo(): Boolean {
val details = inReplyTo()
return details is InReplyTo.NotLoaded

View File

@@ -68,6 +68,7 @@ class EventMessageMapper {
body = it.body(),
inReplyTo = inReplyToEvent,
isEdited = it.isEdited(),
isThreaded = it.isThreaded(),
type = type
)
}

View File

@@ -147,6 +147,7 @@ fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
isThreaded: Boolean = false,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
@@ -155,6 +156,7 @@ fun aMessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
isThreaded = isThreaded,
type = messageType
)

View File

@@ -41,6 +41,7 @@ sealed interface MessageComposerMode : Parcelable {
class Reply(
val senderName: String,
val attachmentThumbnailInfo: AttachmentThumbnailInfo?,
val isThreaded: Boolean,
override val eventId: EventId,
override val defaultContent: String
) : Special(eventId, defaultContent)
@@ -60,5 +61,5 @@ sealed interface MessageComposerMode : Parcelable {
get() = this is Reply
val inThread: Boolean
get() = false // TODO
get() = this is Reply && isThreaded
}

View File

@@ -185,9 +185,13 @@ fun TextComposer(
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
TextInput(
state = state,
placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else {
stringResource(id = CommonStrings.rich_text_editor_composer_placeholder)
},
roundedCorners = roundedCorners,
bgColor = bgColor,
onError = onError,
@@ -239,6 +243,7 @@ fun TextComposer(
@Composable
private fun TextInput(
state: RichTextEditorState,
placeholder: String,
roundedCorners: RoundedCornerShape,
bgColor: Color,
modifier: Modifier = Modifier,
@@ -265,7 +270,7 @@ private fun TextInput(
// Placeholder
if (state.messageHtml.isEmpty()) {
Text(
stringResource(CommonStrings.common_message),
placeholder,
style = defaultTypography.copy(
color = ElementTheme.colors.textDisabled,
),
@@ -689,6 +694,23 @@ internal fun TextComposerReplyPreview() = ElementPreview {
canSendMessage = false,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = false,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = null,
defaultContent = "A message\n" +
"With several lines\n" +
"To preview larger textfields and long lines with overflow"
),
onResetComposerMode = {},
enableTextFormatting = true,
)
TextComposer(
RichTextEditorState("", fake = true),
canSendMessage = false,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = true,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = null,
@@ -704,6 +726,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = true,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
@@ -722,6 +745,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = false,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
@@ -740,6 +764,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = false,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
@@ -758,6 +783,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
isThreaded = false,
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(