Add thread decoration with latest event details (#5355)

* Add thread decoration with latest event details
* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-09-23 16:57:50 +02:00 committed by GitHub
parent 5cadd37fa6
commit 0a5c178fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 554 additions and 282 deletions

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
@ -328,7 +329,10 @@ class MessagesPresenter(
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
if (displayThreads) {
// Get either the thread id this event is in, or the event id if it's not in a thread so we can start one
val threadId = targetEvent.threadInfo.threadRootId ?: targetEvent.eventId!!.toThreadId()
val threadId = when (targetEvent.threadInfo) {
is TimelineItemThreadInfo.ThreadResponse -> targetEvent.threadInfo.threadRootId
is TimelineItemThreadInfo.ThreadRoot, null -> targetEvent.eventId?.toThreadId()
} ?: return@launch
navigator.onOpenThread(threadId, null)
} else {
handleActionReply(targetEvent, composerState, timelineProtectionState)

View file

@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@ -174,7 +175,7 @@ class DefaultActionListPresenter(
add(TimelineItemAction.ReplyInThread)
add(TimelineItemAction.Reply)
} else {
if (!isThreadsEnabled && timelineItem.threadInfo.threadRootId != null) {
if (!isThreadsEnabled && timelineItem.threadInfo is TimelineItemThreadInfo.ThreadResponse) {
// If threads are not enabled, we can reply in a thread if the item is already in the thread
add(TimelineItemAction.ReplyInThread)
} else {

View file

@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@ -32,7 +33,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -146,7 +146,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: TimelineItemThreadInfo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),

View file

@ -53,8 +53,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.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 78% now.
private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@Composable
@ -66,34 +64,6 @@ fun MessageEventBubble(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {},
) {
fun bubbleShape(): Shape {
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
return when (state.groupPosition) {
TimelineItemGroupPosition.First -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Middle -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Last -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
}
TimelineItemGroupPosition.None ->
RoundedCornerShape(
topLeftCorner,
BUBBLE_RADIUS,
BUBBLE_RADIUS,
BUBBLE_RADIUS
)
}
}
val clickableModifier = if (isTalkbackActive()) {
Modifier
} else {
@ -108,11 +78,8 @@ fun MessageEventBubble(
}
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when {
state.isMine -> ElementTheme.colors.messageFromMeBackground
else -> ElementTheme.colors.messageFromOtherBackground
}
val bubbleShape = bubbleShape()
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@ -147,7 +114,7 @@ fun MessageEventBubble(
.testTag(TestTags.messageBubble)
.widthIn(
min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO)
max = (constraints.maxWidth * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO)
.toInt()
.toDp()
)
@ -157,6 +124,48 @@ fun MessageEventBubble(
}
}
object MessageEventBubbleDefaults {
fun shape(cutTopStart: Boolean, groupPosition: TimelineItemGroupPosition, isMine: Boolean): Shape {
val topLeftCorner = if (cutTopStart) 0.dp else BUBBLE_RADIUS
return when (groupPosition) {
TimelineItemGroupPosition.First -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Middle -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Last -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
}
TimelineItemGroupPosition.None ->
RoundedCornerShape(
topLeftCorner,
BUBBLE_RADIUS,
BUBBLE_RADIUS,
BUBBLE_RADIUS
)
}
}
@Composable
fun backgroundBubbleColor(isMine: Boolean): Color {
return if (isMine) {
ElementTheme.colors.messageFromMeBackground
} else {
ElementTheme.colors.messageFromOtherBackground
}
}
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
const val BUBBLE_WIDTH_RATIO = 0.78f
}
@PreviewsDayNight
@Composable
internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview {

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -23,6 +24,8 @@ 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.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -34,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
@ -43,6 +47,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@ -61,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -78,25 +84,28 @@ 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
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -256,22 +265,22 @@ fun TimelineItemEventRow(
)
}
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) {
event.threadInfo.threadSummary?.let { threadSummary ->
val threadPart = stringResource(CommonStrings.common_thread)
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies ->
pluralStringResource(CommonPlurals.common_replies, replies, replies)
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
ThreadSummaryView(
modifier = if (event.isMine) {
Modifier.align(Alignment.End).padding(end = 16.dp)
} else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
}.padding(top = 2.dp),
threadSummary = event.threadInfo.summary,
latestEventText = event.threadInfo.latestEventText,
isOutgoing = event.isMine,
onClick = {
event.eventId?.let {
eventSink(TimelineEvents.OpenThread(it.toThreadId(), null))
}
}
Button(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp)
.align(if (event.isMine) Alignment.End else Alignment.Start),
text = "$threadPart - $numberOfReplies",
size = ButtonSize.Small,
onClick = {
eventSink(TimelineEvents.OpenThread(event.eventId!!.toThreadId(), null))
},
)
}
)
}
// Read receipts / Send state
@ -288,6 +297,79 @@ fun TimelineItemEventRow(
}
}
@Composable
private fun ThreadSummaryView(
threadSummary: ThreadSummary,
latestEventText: String?,
isOutgoing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier) {
Row(
modifier = Modifier
.then(if (!isOutgoing) Modifier.padding(start = 16.dp) else Modifier)
.graphicsLayer {
shape = RoundedCornerShape(8.dp)
clip = true
}
.background(MessageEventBubbleDefaults.backgroundBubbleColor(isOutgoing))
.niceClickable(onClick)
.padding(horizontal = 12.dp, vertical = 10.dp)
.widthIn(max = (maxWidth - 24.dp) * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.ThreadsSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = pluralStringResource(CommonPlurals.common_replies, threadSummary.numberOfReplies.toInt(), threadSummary.numberOfReplies),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.width(8.dp))
threadSummary.latestEvent.dataOrNull()?.let { latestEvent ->
val avatarData = AvatarData(
id = latestEvent.senderId.value,
name = latestEvent.senderProfile.getDisplayName(),
url = latestEvent.senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineThreadLatestEventSender,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.width(4.dp))
latestEventText?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
}
}
/**
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
@ -694,7 +776,7 @@ private fun MessageEventBubbleContent(
else -> ContentPadding.Textual
}
CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null,
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo,
@ -746,9 +828,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L)
threadInfo = TimelineItemThreadInfo.ThreadRoot(
latestEventText = "This is the latest message in the thread",
summary = ThreadSummary(AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
),
timestamp = 0L,
)
), numberOfReplies = 20L)
)
),
displayThreadSummaries = true,
@ -756,3 +856,40 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadSummaryViewPreview() {
ElementPreview {
val body = "This is the latest message in the thread"
val threadSummary = ThreadSummary(
AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = body,
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(body, null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = true,
),
timestamp = 0L,
)
),
numberOfReplies = 12,
)
ThreadSummaryView(
threadSummary = threadSummary,
latestEventText = "Some event with a very long text that should get clipped",
isOutgoing = true,
onClick = {},
)
}
}

View file

@ -13,12 +13,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
@ -58,10 +58,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = null,
),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = ThreadId("\$thread-root-id")),
groupPosition = TimelineItemGroupPosition.Last,
),
)

View file

@ -12,7 +12,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -20,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInv
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@Inject
class TimelineItemContentFactory(
@ -40,26 +45,53 @@ class TimelineItemContentFactory(
private val stateFactory: TimelineItemContentStateFactory,
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return when (val itemContent = eventTimelineItem.content) {
return create(
itemContent = eventTimelineItem.content,
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
sender = eventTimelineItem.sender,
senderProfile = eventTimelineItem.senderProfile,
)
}
suspend fun create(
itemContent: EventContent,
eventId: EventId?,
isEditable: Boolean,
sender: UserId,
senderProfile: ProfileTimelineDetails,
): TimelineItemEventContent {
val isOutgoing = currentSessionIdHolder.current == sender
return when (itemContent) {
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
messageFactory.create(
content = itemContent,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
eventId = eventTimelineItem.eventId,
eventId = eventId,
)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is ProfileChangeContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
profileChangeFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is RedactedContent -> redactedMessageFactory.create(itemContent)
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is RoomMembershipContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
roomMembershipFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is LegacyCallInviteContent -> TimelineItemLegacyCallInviteContent
is StateContent -> stateFactory.create(eventTimelineItem)
is StateContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
stateFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(eventTimelineItem, itemContent)
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent()
is UnknownContent -> TimelineItemUnknownContent

View file

@ -11,7 +11,7 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@Inject
@ -19,14 +19,16 @@ class TimelineItemContentPollFactory(
private val pollContentStateFactory: PollContentStateFactory,
) {
suspend fun create(
event: EventTimelineItem,
eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): TimelineItemEventContent {
val pollContentState = pollContentStateFactory.create(event, content)
val pollContentState = pollContentStateFactory.create(eventId, isEditable, isOwn, content)
return TimelineItemPollContent(
isMine = pollContentState.isMine,
isEditable = pollContentState.isPollEditable,
eventId = event.eventId,
eventId = eventId,
question = pollContentState.question,
answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentProfileChangeFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(content, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemProfileChangeContent(text.orEmpty().toString())
}
}

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentRoomMembershipFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemRoomMembershipContent(text.orEmpty().toString())
}
}

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentStateFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemStateEventContent(text.orEmpty().toString())
}
}

View file

@ -19,6 +19,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.libraries.architecture.map
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
@ -43,6 +46,7 @@ class TimelineItemEventFactory(
private val matrixClient: MatrixClient,
private val dateFormatter: DateFormatter,
private val permalinkParser: PermalinkParser,
private val summaryFormatter: MessageSummaryFormatter,
) {
@AssistedFactory
interface Creator {
@ -69,6 +73,29 @@ class TimelineItemEventFactory(
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
val mappedThreadInfo = when (val threadInfo = currentTimelineItem.event.threadInfo()) {
is EventThreadInfo.ThreadResponse -> {
TimelineItemThreadInfo.ThreadResponse(threadInfo.threadRootId)
}
is EventThreadInfo.ThreadRoot -> {
TimelineItemThreadInfo.ThreadRoot(
summary = threadInfo.summary,
latestEventText = threadInfo.summary.latestEvent.dataOrNull()
?.let {
contentFactory.create(
itemContent = it.content,
eventId = it.eventOrTransactionId.eventId,
isEditable = false,
sender = it.senderId,
senderProfile = it.senderProfile,
)
}
?.let(summaryFormatter::format)
)
}
null -> null
}
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
@ -87,7 +114,7 @@ class TimelineItemEventFactory(
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = mappedThreadInfo,
origin = currentTimelineItem.event.origin,
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider,

View file

@ -17,10 +17,11 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -82,7 +83,7 @@ sealed interface TimelineItem {
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?,
val threadInfo: EventThreadInfo,
val threadInfo: TimelineItemThreadInfo?,
val origin: TimelineItemEventOrigin?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,
@ -130,3 +131,8 @@ sealed interface TimelineItem {
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem
}
sealed interface TimelineItemThreadInfo {
data class ThreadRoot(val summary: ThreadSummary, val latestEventText: String?) : TimelineItemThreadInfo
data class ThreadResponse(val threadRootId: ThreadId) : TimelineItemThreadInfo
}

View file

@ -10,9 +10,9 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@ -37,15 +37,15 @@ import io.element.android.libraries.ui.strings.CommonStrings
class DefaultMessageSummaryFormatter(
@ApplicationContext private val context: Context,
) : MessageSummaryFormatter {
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.plainText
is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemStateContent -> event.content.body
override fun format(content: TimelineItemEventContent): String {
return when (content) {
is TimelineItemTextBasedContent -> content.plainText
is TimelineItemProfileChangeContent -> content.body
is TimelineItemStateContent -> content.body
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question
is TimelineItemPollContent -> content.question
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)

View file

@ -8,7 +8,11 @@
package io.element.android.features.messages.impl.utils.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
interface MessageSummaryFormatter {
fun format(event: TimelineItem.Event): String
fun format(event: TimelineItem.Event): String {
return format(event.content)
}
fun format(content: TimelineItemEventContent): String
}

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -61,7 +62,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -1181,7 +1181,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(threadInfo = EventThreadInfo(A_THREAD_ID, null))
event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
))
awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
@ -1204,7 +1204,7 @@ class MessagesPresenterTest {
event = aMessageEvent(
// The event id will be used as the thread id instead
eventId = AN_EVENT_ID,
threadInfo = EventThreadInfo(null, null),
threadInfo = null,
)
))
awaitItem()

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -31,7 +32,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
@ -198,7 +198,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -432,7 +432,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -1264,7 +1264,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, null)
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@ -1368,7 +1368,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
)
assertThat(messageEvent.isRemote).isTrue()

View file

@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -19,7 +20,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
@ -41,7 +41,7 @@ internal fun aMessageEvent(
canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: TimelineItemThreadInfo? = null,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
@ -30,7 +31,9 @@ import io.element.android.features.poll.test.pollcontent.FakePollContentStateFac
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
@ -75,11 +78,13 @@ internal fun TestScope.aTimelineItemsFactory(
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(matrixClient),
),
matrixClient = matrixClient,
dateFormatter = FakeDateFormatter(),
permalinkParser = FakePermalinkParser(),
config = config
config = config,
summaryFormatter = FakeMessageSummaryFormatter(),
)
}
},
@ -95,7 +100,7 @@ internal fun TestScope.aTimelineItemsFactory(
internal fun aTimelineEventFormatter(): TimelineEventFormatter {
return object : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence {
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return ""
}
}

View file

@ -7,13 +7,13 @@
package io.element.android.features.messages.impl.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
class FakeMessageSummaryFormatter : MessageSummaryFormatter {
private var result = "A message"
override fun format(event: TimelineItem.Event): String = result
override fun format(content: TimelineItemEventContent): String = result
fun givenMessageResult(value: String) {
result = value

View file

@ -750,7 +750,7 @@ class TimelineItemContentMessageFactoryTest {
body: String = "Body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: EventThreadInfo? = null,
type: MessageType,
): MessageContent {
return MessageContent(

View file

@ -18,7 +18,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
isEditable = false,
canBeRepliedTo = false,
inReplyTo = null,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
origin = null,
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null },

View file

@ -7,9 +7,18 @@
package io.element.android.features.poll.api.pollcontent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
interface PollContentStateFactory {
suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState
suspend fun create(eventTimelineItem: EventTimelineItem, content: PollContent): PollContentState {
return create(
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
isOwn = eventTimelineItem.isOwn,
content = content,
)
}
suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState
}

View file

@ -45,7 +45,12 @@ class PollHistoryItemsFactory(
return when (timelineItem) {
is MatrixTimelineItem.Event -> {
val pollContent = timelineItem.event.content as? PollContent ?: return null
val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent)
val pollContentState = pollContentStateFactory.create(
eventId = timelineItem.eventId,
isEditable = timelineItem.event.isEditable,
isOwn = timelineItem.event.isOwn,
content = pollContent,
)
PollHistoryItem(
formattedDate = dateFormatter.format(
timestamp = timelineItem.event.timestamp,

View file

@ -14,8 +14,8 @@ import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
@ -25,8 +25,10 @@ class DefaultPollContentStateFactory(
private val matrixClient: MatrixClient,
) : PollContentStateFactory {
override suspend fun create(
event: EventTimelineItem,
content: PollContent
eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): PollContentState {
val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
@ -59,13 +61,13 @@ class DefaultPollContentStateFactory(
}
return PollContentState(
eventId = event.eventId,
eventId = eventId,
question = content.question,
answerItems = answerItems.toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEditable = isEditable,
isPollEnded = isPollEnded,
isMine = event.isOwn,
isMine = isOwn,
)
}
}

View file

@ -10,20 +10,20 @@ package io.element.android.features.poll.test.pollcontent
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
class FakePollContentStateFactory : PollContentStateFactory {
override suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState {
override suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState {
return PollContentState(
eventId = event.eventId,
eventId = eventId,
question = content.question,
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEditable = isEditable,
isPollEnded = content.endTime != null,
isMine = event.isOwn
isMine = isOwn,
)
}
}

View file

@ -34,6 +34,7 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp),

View file

@ -7,8 +7,19 @@
package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
interface TimelineEventFormatter {
fun format(event: EventTimelineItem): CharSequence?
fun format(event: EventTimelineItem): CharSequence? {
return format(
content = event.content,
isOutgoing = event.isOwn,
sender = event.sender,
senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
)
}
fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence?
}

View file

@ -13,7 +13,9 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -43,12 +45,16 @@ class DefaultTimelineEventFormatter(
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
return format(event.content, isOutgoing, event.sender, senderDisambiguatedDisplayName)
}
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return when (content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
@ -66,7 +72,7 @@ class DefaultTimelineEventFormatter(
is FailedToParseStateContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event: $event")
error("You should not use this formatter for this event content: $content")
}
sp.getString(CommonStrings.common_unsupported_event)
}

View file

@ -14,7 +14,6 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -175,7 +174,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
) {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,7 +14,6 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -130,7 +129,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Message contents`() {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,10 +14,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class EventThreadInfo(
val threadRootId: ThreadId?,
val threadSummary: ThreadSummary?,
)
sealed interface EventThreadInfo {
data class ThreadRoot(val summary: ThreadSummary) : EventThreadInfo
data class ThreadResponse(val threadRootId: ThreadId) : EventThreadInfo
}
data class ThreadSummary(
val latestEvent: AsyncData<EmbeddedEventInfo>,

View file

@ -24,7 +24,7 @@ data class MessageContent(
val body: String,
val inReplyTo: InReplyTo?,
val isEdited: Boolean,
val threadInfo: EventThreadInfo,
val threadInfo: EventThreadInfo?,
val type: MessageType
) : EventContent

View file

@ -38,7 +38,7 @@ private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo): MessageContent = message.use {
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo?): MessageContent = message.use {
val type = it.content.msgType.use(this::mapMessageType)
val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map)
MessageContent(

View file

@ -79,7 +79,7 @@ class TimelineEventContentMapper(
content = map(latestEvent.content),
senderId = UserId(latestEvent.sender),
senderProfile = latestEvent.senderProfile.map(),
timestamp = latestEvent.timestamp.toLong()
timestamp = latestEvent.timestamp.toLong(),
)
)
}
@ -89,10 +89,12 @@ class TimelineEventContentMapper(
numberOfReplies = numberOfReplies,
)
}
val threadInfo = EventThreadInfo(
threadRootId = it.content.threadRoot?.let(::ThreadId),
threadSummary = threadSummary,
)
val threadRootId = it.content.threadRoot?.let(::ThreadId)
val threadInfo = when {
threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary)
threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId)
else -> null
}
eventMessageMapper.map(kind, inReplyTo, threadInfo)
}
is MsgLikeKind.Redacted -> {

View file

@ -104,7 +104,7 @@ fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: EventThreadInfo? = null,
messageType: MessageType = TextMessageType(
body = body,
formatted = null

View file

@ -134,10 +134,7 @@ class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
private fun aMessageContent(
body: String,
type: MessageType,
threadInfo: EventThreadInfo = EventThreadInfo(
threadRootId = null,
threadSummary = null,
),
threadInfo: EventThreadInfo? = null,
) = MessageContent(
body = body,
inReplyTo = null,

View file

@ -8,7 +8,6 @@
package io.element.android.libraries.matrix.ui.messages.reply
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@ -70,7 +69,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = FormattedBody(
@ -95,7 +94,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = null,

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.textcomposer.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -49,7 +50,7 @@ sealed interface MessageComposerMode {
get() = this is Reply &&
replyToDetails is InReplyToDetails.Ready &&
replyToDetails.eventContent is MessageContent &&
(replyToDetails.eventContent as MessageContent).threadInfo.threadRootId != null
(replyToDetails.eventContent as MessageContent).threadInfo is EventThreadInfo.ThreadResponse
}
fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean {

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2cb3989055cf63e3ee14d8e2d8b2cdcd67b3af1addae78b38ab13ddcf93a232d
size 9740

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e206a2485736baa2ac8c6dae6847ef381bfa1f36a5b7bda14a7f93a55069f41
size 9666

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddaa247eea635d7e8c079ae128983fd25c1aeba96a48b08ae71218ef52e88d5b
size 69475
oid sha256:b51cd7375e0d9be97786632eb1dfc16b5023784ed0de616f029d782ca56aa07b
size 68221

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:647365688fd0c542d4be6dae570b33fbb7bab6261dc57791db85966808d440bb
size 67563
oid sha256:1cb184afd2b1afff7b2f5f24f555e8278e20bbc98fe8b67a25a005771c7105b0
size 66908

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26b9908bbd388321444037dcc1aae55037d3dabc7a9f9b14c39ba871f4f9d593
size 16128
oid sha256:9640e8e6d758a03f995c7fbbb6c3c123109594138a7cc53d5416ef5e3e157693
size 18006

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a96b6d95b8941d80035b309444b9eaa038098fb16aa84dec209fc3ee215ac9e
size 21687
oid sha256:fbe699fb17947981c53d47a9570da71e2388ec493a3a9471aaa13a5405075069
size 23421

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce1aba18ea8a6b45d9c40de9af961952e261c7c6200ab13cdfa67906d995208b
size 14888
oid sha256:8c6688901f89d3858ec67dda889fe589fb6b59202c64a4b700c2aa484e19f4cd
size 17606

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a4d295ae71ba2709845312b84983b79348069ded46b30091b9fa769a587cbb7
size 14337
oid sha256:26b9908bbd388321444037dcc1aae55037d3dabc7a9f9b14c39ba871f4f9d593
size 16128

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8f54591a52475c6f47fa083a1bb397e1164dd3e735562388fde4ff45644150
size 16275
oid sha256:1a96b6d95b8941d80035b309444b9eaa038098fb16aa84dec209fc3ee215ac9e
size 21687

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce1aba18ea8a6b45d9c40de9af961952e261c7c6200ab13cdfa67906d995208b
size 14888

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a4d295ae71ba2709845312b84983b79348069ded46b30091b9fa769a587cbb7
size 14337

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8f54591a52475c6f47fa083a1bb397e1164dd3e735562388fde4ff45644150
size 16275

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd18a15bc49e87b8abc9231a13771a0fe34003b11fe1fd48df2d91f54bc40cc8
size 15564
oid sha256:3d5ed3a4c0340d904404c1ef68e7391e1303f865d15e88de6f82ed68e8dc4b4c
size 19463

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:388a1ac9f1790fd305fd329b73ae621093d323db3fa4d4d32eef92f8dd5b51ec
size 14824
oid sha256:c44464ab02dd9f1fc0624f2110bc9938c8aa8856bdad811aafa05eff2e144e4a
size 18884

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc90e78d2fa94425322895028f1156bfda196ee2a761231920c6ffd80f02984b
size 17512
oid sha256:57145b520f6702934571122f4c243f8740658847c0c60e6f901179d1d420d16a
size 20857

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74fda6437495995876f76dc1ff0e056de4937ef8d719b3be1ce73baccd31516e
size 15889
oid sha256:fd18a15bc49e87b8abc9231a13771a0fe34003b11fe1fd48df2d91f54bc40cc8
size 15564

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04fb2f230a09f0d3e27e151d98aa5d91e416b5ca9637b4db5d8e00118a68e7c4
size 15166
oid sha256:388a1ac9f1790fd305fd329b73ae621093d323db3fa4d4d32eef92f8dd5b51ec
size 14824

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:896f698ef11d8a0577baf0a81cb54cabc307d95d05c230e5e3bdde40c3dc0900
size 17844
oid sha256:bc90e78d2fa94425322895028f1156bfda196ee2a761231920c6ffd80f02984b
size 17512

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf264b6f8af5b7432511d595ff8c663c5e2be33c9f85268627f5188e3f0f8db0
size 18949
oid sha256:74fda6437495995876f76dc1ff0e056de4937ef8d719b3be1ce73baccd31516e
size 15889

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0bbe1e3e8e1ea119cc61509617d3fa8e2bd047f619c41271901c73c46be1d610
size 18201
oid sha256:04fb2f230a09f0d3e27e151d98aa5d91e416b5ca9637b4db5d8e00118a68e7c4
size 15166

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a7c183808801645e285dfba563c036c204a347de20b4b1e40fcfeab29fafb7d
size 20876
oid sha256:896f698ef11d8a0577baf0a81cb54cabc307d95d05c230e5e3bdde40c3dc0900
size 17844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a48eb99f4466e3a883c0c7441006f201e0db22fd0e920ad0a77a8512637e01bb
size 16445
oid sha256:cf264b6f8af5b7432511d595ff8c663c5e2be33c9f85268627f5188e3f0f8db0
size 18949

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:895ba9a35b8854d5005fc0671a7e6cbba5e26525b4a8ed4fa0fb612432caa04e
size 15205
oid sha256:0bbe1e3e8e1ea119cc61509617d3fa8e2bd047f619c41271901c73c46be1d610
size 18201

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98baed819cd8b08085ceabf7dbccccc77b0fdf0d28a3f852879f6f8aa02ee441
size 19848
oid sha256:7a7c183808801645e285dfba563c036c204a347de20b4b1e40fcfeab29fafb7d
size 20876

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4678c15b608547c255d5eeb128c144ae7a3c5a21de2b047fc77f15364822ac2
size 12847
oid sha256:a48eb99f4466e3a883c0c7441006f201e0db22fd0e920ad0a77a8512637e01bb
size 16445

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e30395594c1a35d90fc0a04af340eb79cbe686d90856414d0024b769f86e89d
size 12507
oid sha256:895ba9a35b8854d5005fc0671a7e6cbba5e26525b4a8ed4fa0fb612432caa04e
size 15205

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60362244c7adf3644ed2ae354643e0525aad2a4454f6b13abce8fd265f4987c6
size 13777
oid sha256:98baed819cd8b08085ceabf7dbccccc77b0fdf0d28a3f852879f6f8aa02ee441
size 19848

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73de8ee7933c1a45a266761b14e09b008a62dc69b1600bbc69b8a01370fb97c1
size 18641
oid sha256:c4678c15b608547c255d5eeb128c144ae7a3c5a21de2b047fc77f15364822ac2
size 12847

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b62b7dc22bfc727cfcbf7f6a19683bcf4da116c7192f9943f5addd11c39fcadb
size 17018
oid sha256:5e30395594c1a35d90fc0a04af340eb79cbe686d90856414d0024b769f86e89d
size 12507

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0855112283bf797bc846e1982c6293e321361368481a803e12658723aaf8409
size 22988
oid sha256:60362244c7adf3644ed2ae354643e0525aad2a4454f6b13abce8fd265f4987c6
size 13777

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:049b3ac784a8e400bad374a960ecb77d7f4bca81079be764dc74cb161f7a1093
size 20880
oid sha256:73de8ee7933c1a45a266761b14e09b008a62dc69b1600bbc69b8a01370fb97c1
size 18641

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21d83d719bee5fd19ad090791c7848890d87e4bd9eaddb17c9da1773ec81667f
size 19249
oid sha256:b62b7dc22bfc727cfcbf7f6a19683bcf4da116c7192f9943f5addd11c39fcadb
size 17018

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:edc743a5cd067eb13e464f27c2f12f95433bd020dd1b3e920614ee913e06e476
size 25008
oid sha256:c0855112283bf797bc846e1982c6293e321361368481a803e12658723aaf8409
size 22988

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:45e6821d622bfef22cc41a040e12e2b49e0bb50a88b0a143bdb25e3368809412
size 16496
oid sha256:049b3ac784a8e400bad374a960ecb77d7f4bca81079be764dc74cb161f7a1093
size 20880

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ddd2399d3d701fa962dc5683e054c2c4829ccd99d04498415ce88487f5e0ec8
size 15760
oid sha256:21d83d719bee5fd19ad090791c7848890d87e4bd9eaddb17c9da1773ec81667f
size 19249

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4293d98ed31f87c59ff5741344c2e41f58ff8f19c9342f6626553e978dbfc674
size 18429
oid sha256:edc743a5cd067eb13e464f27c2f12f95433bd020dd1b3e920614ee913e06e476
size 25008

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37d1e302d4a610aaab7cfc6a15572b7eb92b059d9384e7bbe4daebcaf2da9049
size 21343
oid sha256:45e6821d622bfef22cc41a040e12e2b49e0bb50a88b0a143bdb25e3368809412
size 16496

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:536d8d899945483352137d23356f3777db8e2225288f01e8cbcd1a770ca7f3ee
size 20494
oid sha256:6ddd2399d3d701fa962dc5683e054c2c4829ccd99d04498415ce88487f5e0ec8
size 15760

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:542b080ae9374e4f6d57689b1e1b6a84a0f81f017bd276bbcab43b9659be4335
size 23534
oid sha256:4293d98ed31f87c59ff5741344c2e41f58ff8f19c9342f6626553e978dbfc674
size 18429

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aeb02390258eade3ceb8c437bcf3592f3dde463d6f1b2a43ffefd61abf4184c4
size 17236
oid sha256:37d1e302d4a610aaab7cfc6a15572b7eb92b059d9384e7bbe4daebcaf2da9049
size 21343

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5c6bfe22fb71a9c6353718c2124000245182359ac1c43dfc8dd9b91415e66e6
size 16385
oid sha256:536d8d899945483352137d23356f3777db8e2225288f01e8cbcd1a770ca7f3ee
size 20494

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:408f90e8a2f4dcd0716118fb522e5cd4adba0b2a429723fc2e47555b82acf005
size 19501
oid sha256:542b080ae9374e4f6d57689b1e1b6a84a0f81f017bd276bbcab43b9659be4335
size 23534

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9778edbbb33ab0bbaa0072bd4659488937efe0f8b8fac0a5b64497fe6a1e28e2
size 20838
oid sha256:aeb02390258eade3ceb8c437bcf3592f3dde463d6f1b2a43ffefd61abf4184c4
size 17236

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97f43860bf9e65c7822943f8806a0642ce59957be5e35650e2b69a2c562a694c
size 18637
oid sha256:d5c6bfe22fb71a9c6353718c2124000245182359ac1c43dfc8dd9b91415e66e6
size 16385

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:77e71b006b71300ccb1005ad3cac6a93ae242ff03f2936fdf9d10c0604d20faa
size 26121
oid sha256:408f90e8a2f4dcd0716118fb522e5cd4adba0b2a429723fc2e47555b82acf005
size 19501

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e744d3fe3315dc6cb5beab80dc29d565500367772339071575b6505370e47207
size 14771
oid sha256:9778edbbb33ab0bbaa0072bd4659488937efe0f8b8fac0a5b64497fe6a1e28e2
size 20838

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:448ea365e3e75059bc888961e3454562d35ab116c75b3b9855723b5fc1480d92
size 14028
oid sha256:97f43860bf9e65c7822943f8806a0642ce59957be5e35650e2b69a2c562a694c
size 18637

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad98105fcd6d6c02e5ae036ad49770b4aa35d94e32ab3a63f040deccc2375e6e
size 16703
oid sha256:77e71b006b71300ccb1005ad3cac6a93ae242ff03f2936fdf9d10c0604d20faa
size 26121

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b8e747d51d6a6ab1ab6ff80224d0241a16f38b39e0c156dc6eef47ea56eca63
size 18140
oid sha256:e744d3fe3315dc6cb5beab80dc29d565500367772339071575b6505370e47207
size 14771

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34f28c6dbbe094b9ba070c6f1ef4b10b2625ea094629b6305dff70bdf6367ddc
size 16894
oid sha256:448ea365e3e75059bc888961e3454562d35ab116c75b3b9855723b5fc1480d92
size 14028

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ccfe44da86dce16ba4816cfe2029c05d317fe29ca3bb816278f2bd929298d855
size 21532
oid sha256:ad98105fcd6d6c02e5ae036ad49770b4aa35d94e32ab3a63f040deccc2375e6e
size 16703

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5b77bec92bc7c4da3fdc72831797d685bf042131c2222351cc7c852da48cd60
size 17662
oid sha256:5b8e747d51d6a6ab1ab6ff80224d0241a16f38b39e0c156dc6eef47ea56eca63
size 18140

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99f2b0e2fda05d6fa16c0f2dcc52e92f6fa1c656c6d84cf03e8507a5c14104e8
size 16932
oid sha256:34f28c6dbbe094b9ba070c6f1ef4b10b2625ea094629b6305dff70bdf6367ddc
size 16894

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e79cb41e125705af70990dec3e3fdc0a0202ec8d62f2077eb2a298243ae2e94d
size 19624
oid sha256:ccfe44da86dce16ba4816cfe2029c05d317fe29ca3bb816278f2bd929298d855
size 21532

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0faccb345f65a543c56e4db44714614915a62b6916a77a0f4adfdae5215311e
size 15128
oid sha256:e5b77bec92bc7c4da3fdc72831797d685bf042131c2222351cc7c852da48cd60
size 17662

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d626da2924325c074dcd2baf1c112430399553a9ee652cac03b0b1d3db4cff3
size 14393
oid sha256:99f2b0e2fda05d6fa16c0f2dcc52e92f6fa1c656c6d84cf03e8507a5c14104e8
size 16932

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2be7c565d30e9f45a583b93ec948a7cb35a4bc872c4f479bd1efe9eda8972e7c
size 17044
oid sha256:e79cb41e125705af70990dec3e3fdc0a0202ec8d62f2077eb2a298243ae2e94d
size 19624

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c28eae20f614eae53e09aa4e396792f36ee1478c8141faad788fe397502da237
size 20671
oid sha256:e0faccb345f65a543c56e4db44714614915a62b6916a77a0f4adfdae5215311e
size 15128

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ada8d054436b15e9029150eb99e78103050f8d0428a1c78ef5667bfd54d3be5
size 19195
oid sha256:5d626da2924325c074dcd2baf1c112430399553a9ee652cac03b0b1d3db4cff3
size 14393

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7586db3dc8687c5c5fc73d28dd968041b192c160286f6a7c881eb7d86a2d1bd2
size 24477
oid sha256:2be7c565d30e9f45a583b93ec948a7cb35a4bc872c4f479bd1efe9eda8972e7c
size 17044

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ef75decb9f501eb6c1941e212e1b330203c16458bfc2e10bfac3d197b091a35
size 17116
oid sha256:c28eae20f614eae53e09aa4e396792f36ee1478c8141faad788fe397502da237
size 20671

Some files were not shown because too many files have changed in this diff Show more