Use formatted captions for images and video (#3864)
* Make `formattedCaption in `TimelineItemEventContentWithAttachment` a `Charsequence?`, parse the formatted caption body as we do for text message bodies * Add `TimelineItem.isWholeContentClickable` property to decide whether the click action should be triggered at the message bubble level or when some internal content is tapped instead. * Display the formatted/linkified captions in image and video timeline item views * Apply the `onClick` callback to the whole message bubble or only the content of the timeline item depending on `TimelineItem.isWholeContentClickable`.
This commit is contained in:
parent
74c996fbd8
commit
87976694d4
28 changed files with 129 additions and 86 deletions
|
|
@ -212,7 +212,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = this::navigateUp,
|
onBackClick = this::navigateUp,
|
||||||
onRoomDetailsClick = this::onRoomDetailsClick,
|
onRoomDetailsClick = this::onRoomDetailsClick,
|
||||||
onEventClick = this::onEventClick,
|
onEventContentClick = this::onEventClick,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
onUserDataClick = this::onUserDataClick,
|
onUserDataClick = this::onUserDataClick,
|
||||||
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
|
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onRoomDetailsClick: () -> Unit,
|
onRoomDetailsClick: () -> Unit,
|
||||||
onEventClick: (event: TimelineItem.Event) -> Boolean,
|
onEventContentClick: (event: TimelineItem.Event) -> Boolean,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
|
|
@ -142,9 +142,9 @@ fun MessagesView(
|
||||||
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||||
val localView = LocalView.current
|
val localView = LocalView.current
|
||||||
|
|
||||||
fun onMessageClick(event: TimelineItem.Event) {
|
fun onContentClick(event: TimelineItem.Event) {
|
||||||
Timber.v("onMessageClick= ${event.id}")
|
Timber.v("onMessageClick= ${event.id}")
|
||||||
val hideKeyboard = onEventClick(event)
|
val hideKeyboard = onEventContentClick(event)
|
||||||
if (hideKeyboard) {
|
if (hideKeyboard) {
|
||||||
localView.hideKeyboard()
|
localView.hideKeyboard()
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +206,7 @@ fun MessagesView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding),
|
.consumeWindowInsets(padding),
|
||||||
onMessageClick = ::onMessageClick,
|
onContentClick = ::onContentClick,
|
||||||
onMessageLongClick = ::onMessageLongClick,
|
onMessageLongClick = ::onMessageLongClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
|
|
@ -306,7 +306,7 @@ private fun AttachmentStateView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessagesViewContent(
|
private fun MessagesViewContent(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
onMessageClick: (TimelineItem.Event) -> Unit,
|
onContentClick: (TimelineItem.Event) -> Unit,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -382,7 +382,7 @@ private fun MessagesViewContent(
|
||||||
timelineProtectionState = state.timelineProtectionState,
|
timelineProtectionState = state.timelineProtectionState,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onMessageClick = onMessageClick,
|
onContentClick = onContentClick,
|
||||||
onMessageLongClick = onMessageLongClick,
|
onMessageLongClick = onMessageLongClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
|
@ -568,7 +568,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onRoomDetailsClick = {},
|
onRoomDetailsClick = {},
|
||||||
onEventClick = { false },
|
onEventContentClick = { false },
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onPreviewAttachments = {},
|
onPreviewAttachments = {},
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||||
),
|
),
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onRoomDetailsClick = {},
|
onRoomDetailsClick = {},
|
||||||
onEventClick = { false },
|
onEventContentClick = { false },
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onPreviewAttachments = {},
|
onPreviewAttachments = {},
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ private fun PinnedMessagesListLoaded(
|
||||||
focusedEventId = null,
|
focusedEventId = null,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onClick = onEventClick,
|
onContentClick = onEventClick,
|
||||||
onLongClick = ::onMessageLongClick,
|
onLongClick = ::onMessageLongClick,
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
|
@ -230,6 +230,7 @@ private fun PinnedMessagesListLoaded(
|
||||||
TimelineItemEventContentViewWrapper(
|
TimelineItemEventContentViewWrapper(
|
||||||
event = event,
|
event = event,
|
||||||
timelineProtectionState = state.timelineProtectionState,
|
timelineProtectionState = state.timelineProtectionState,
|
||||||
|
onContentClick = { onEventClick(event) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
onContentLayoutChange = onContentLayoutChange
|
onContentLayoutChange = onContentLayoutChange
|
||||||
|
|
@ -244,6 +245,7 @@ private fun PinnedMessagesListLoaded(
|
||||||
private fun TimelineItemEventContentViewWrapper(
|
private fun TimelineItemEventContentViewWrapper(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
timelineProtectionState: TimelineProtectionState,
|
timelineProtectionState: TimelineProtectionState,
|
||||||
|
onContentClick: () -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -258,10 +260,11 @@ private fun TimelineItemEventContentViewWrapper(
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||||
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
eventSink = { },
|
eventSink = { },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
onContentClick = onContentClick,
|
||||||
onContentLayoutChange = onContentLayoutChange
|
onContentLayoutChange = onContentLayoutChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ fun TimelineView(
|
||||||
timelineProtectionState: TimelineProtectionState,
|
timelineProtectionState: TimelineProtectionState,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onMessageClick: (TimelineItem.Event) -> Unit,
|
onContentClick: (TimelineItem.Event) -> Unit,
|
||||||
onMessageLongClick: (TimelineItem.Event) -> Unit,
|
onMessageLongClick: (TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -141,7 +141,7 @@ fun TimelineView(
|
||||||
focusedEventId = state.focusedEventId,
|
focusedEventId = state.focusedEventId,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onClick = onMessageClick,
|
onContentClick = onContentClick,
|
||||||
onLongClick = onMessageLongClick,
|
onLongClick = onMessageLongClick,
|
||||||
inReplyToClick = ::inReplyToClick,
|
inReplyToClick = ::inReplyToClick,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
|
@ -322,7 +322,7 @@ internal fun TimelineViewPreview(
|
||||||
timelineProtectionState = aTimelineProtectionState(),
|
timelineProtectionState = aTimelineProtectionState(),
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onMessageClick = {},
|
onContentClick = {},
|
||||||
onMessageLongClick = {},
|
onMessageLongClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
||||||
timelineProtectionState = aTimelineProtectionState(),
|
timelineProtectionState = aTimelineProtectionState(),
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onMessageClick = {},
|
onContentClick = {},
|
||||||
onMessageLongClick = {},
|
onMessageLongClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ internal fun ATimelineItemEventRow(
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
onClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ fun TimelineItemEventRow(
|
||||||
renderReadReceipts: Boolean,
|
renderReadReceipts: Boolean,
|
||||||
isLastOutgoingMessage: Boolean,
|
isLastOutgoingMessage: Boolean,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
onClick: () -> Unit,
|
onContentClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
|
|
@ -130,7 +130,8 @@ fun TimelineItemEventRow(
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||||
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
onContentClick = onContentClick,
|
||||||
|
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
|
|
@ -150,6 +151,12 @@ fun TimelineItemEventRow(
|
||||||
inReplyToClick(inReplyToEventId)
|
inReplyToClick(inReplyToEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onWholeItemClick = if (event.isWholeContentClickable) {
|
||||||
|
onContentClick
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
if (event.groupPosition.isNew()) {
|
if (event.groupPosition.isNew()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
@ -173,7 +180,7 @@ fun TimelineItemEventRow(
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onContentClick = onWholeItemClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = ::inReplyToClick,
|
inReplyToClick = ::inReplyToClick,
|
||||||
onUserDataClick = ::onUserDataClick,
|
onUserDataClick = ::onUserDataClick,
|
||||||
|
|
@ -207,7 +214,7 @@ fun TimelineItemEventRow(
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onContentClick = onWholeItemClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = ::inReplyToClick,
|
inReplyToClick = ::inReplyToClick,
|
||||||
onUserDataClick = ::onUserDataClick,
|
onUserDataClick = ::onUserDataClick,
|
||||||
|
|
@ -263,7 +270,7 @@ private fun TimelineItemEventRowContent(
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
timelineRoomInfo: TimelineRoomInfo,
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
onClick: () -> Unit,
|
onContentClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
inReplyToClick: () -> Unit,
|
inReplyToClick: () -> Unit,
|
||||||
onUserDataClick: () -> Unit,
|
onUserDataClick: () -> Unit,
|
||||||
|
|
@ -340,7 +347,7 @@ private fun TimelineItemEventRowContent(
|
||||||
},
|
},
|
||||||
state = bubbleState,
|
state = bubbleState,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onContentClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
) {
|
) {
|
||||||
MessageEventBubbleContent(
|
MessageEventBubbleContent(
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,11 @@ fun TimelineItemGroupedEventsRow(
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||||
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
|
onContentClick = {},
|
||||||
onContentLayoutChange = onContentLayoutChange
|
onContentLayoutChange = onContentLayoutChange
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -121,10 +122,11 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||||
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
|
onContentClick = {},
|
||||||
onContentLayoutChange = onContentLayoutChange
|
onContentLayoutChange = onContentLayoutChange
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -152,7 +154,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
focusedEventId = focusedEventId,
|
focusedEventId = focusedEventId,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onClick = onClick,
|
onContentClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
|
||||||
import io.element.android.libraries.designsystem.text.toPx
|
import io.element.android.libraries.designsystem.text.toPx
|
||||||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -44,7 +43,7 @@ internal fun TimelineItemRow(
|
||||||
focusedEventId: EventId?,
|
focusedEventId: EventId?,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onContentClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -60,7 +59,8 @@ internal fun TimelineItemRow(
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||||
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||||
|
onContentClick = { onContentClick(event) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
|
|
@ -95,7 +95,7 @@ internal fun TimelineItemRow(
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||||
onClick = { onClick(timelineItem) },
|
onClick = { onContentClick(timelineItem) },
|
||||||
onReadReceiptsClick = onReadReceiptClick,
|
onReadReceiptsClick = onReadReceiptClick,
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
|
|
@ -118,11 +118,7 @@ internal fun TimelineItemRow(
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||||
onClick = if (timelineProtectionState.hideMediaContent(timelineItem.eventId) && timelineItem.mustBeProtected()) {
|
onContentClick = { onContentClick(timelineItem) },
|
||||||
{}
|
|
||||||
} else {
|
|
||||||
{ onClick(timelineItem) }
|
|
||||||
},
|
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
|
|
@ -148,7 +144,7 @@ internal fun TimelineItemRow(
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
focusedEventId = focusedEventId,
|
focusedEventId = focusedEventId,
|
||||||
onClick = onClick,
|
onClick = onContentClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,9 @@ fun TimelineItemStateEventRow(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
hideMediaContent = false,
|
hideMediaContent = false,
|
||||||
onShowClick = {},
|
onShowContentClick = {},
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
|
onContentClick = {},
|
||||||
modifier = Modifier.defaultTimelineContentPadding()
|
modifier = Modifier.defaultTimelineContentPadding()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
fun TimelineItemEventContentView(
|
fun TimelineItemEventContentView(
|
||||||
content: TimelineItemEventContent,
|
content: TimelineItemEventContent,
|
||||||
hideMediaContent: Boolean,
|
hideMediaContent: Boolean,
|
||||||
onShowClick: () -> Unit,
|
onContentClick: () -> Unit,
|
||||||
|
onShowContentClick: () -> Unit,
|
||||||
onLinkClick: (url: String) -> Unit,
|
onLinkClick: (url: String) -> Unit,
|
||||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -67,25 +68,31 @@ fun TimelineItemEventContentView(
|
||||||
)
|
)
|
||||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||||
content = content,
|
content = content,
|
||||||
|
onContentClick = onContentClick,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = hideMediaContent,
|
hideMediaContent = hideMediaContent,
|
||||||
onShowClick = onShowClick,
|
onContentClick = onContentClick,
|
||||||
|
onShowContentClick = onShowContentClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
onContentLayoutChange = onContentLayoutChange,
|
onContentLayoutChange = onContentLayoutChange,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = hideMediaContent,
|
hideMediaContent = hideMediaContent,
|
||||||
onShowClick = onShowClick,
|
onContentClick = onContentClick,
|
||||||
|
onShowClick = onShowContentClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = hideMediaContent,
|
hideMediaContent = hideMediaContent,
|
||||||
onShowClick = onShowClick,
|
onContentClick = onContentClick,
|
||||||
|
onShowContentClick = onShowContentClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
onContentLayoutChange = onContentLayoutChange,
|
onContentLayoutChange = onContentLayoutChange,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -50,7 +51,6 @@ import io.element.android.features.messages.impl.timeline.protection.ProtectedVi
|
||||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
|
|
@ -59,7 +59,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
fun TimelineItemImageView(
|
fun TimelineItemImageView(
|
||||||
content: TimelineItemImageContent,
|
content: TimelineItemImageContent,
|
||||||
hideMediaContent: Boolean,
|
hideMediaContent: Boolean,
|
||||||
onShowClick: () -> Unit,
|
onContentClick: () -> Unit,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
onShowContentClick: () -> Unit,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -78,13 +80,14 @@ fun TimelineItemImageView(
|
||||||
) {
|
) {
|
||||||
ProtectedView(
|
ProtectedView(
|
||||||
hideContent = hideMediaContent,
|
hideContent = hideMediaContent,
|
||||||
onShowClick = onShowClick,
|
onShowClick = onShowContentClick,
|
||||||
) {
|
) {
|
||||||
var isLoaded by remember { mutableStateOf(false) }
|
var isLoaded by remember { mutableStateOf(false) }
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
|
.clickable(onClick = onContentClick),
|
||||||
model = content.thumbnailMediaRequestData,
|
model = content.thumbnailMediaRequestData,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.Center,
|
||||||
|
|
@ -99,9 +102,7 @@ fun TimelineItemImageView(
|
||||||
val caption = if (LocalInspectionMode.current) {
|
val caption = if (LocalInspectionMode.current) {
|
||||||
SpannedString(content.caption)
|
SpannedString(content.caption)
|
||||||
} else {
|
} else {
|
||||||
content.formattedCaption?.body
|
content.formattedCaption ?: SpannedString(content.caption)
|
||||||
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
|
|
||||||
?: SpannedString(content.caption)
|
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||||
|
|
@ -114,6 +115,7 @@ fun TimelineItemImageView(
|
||||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||||
text = caption,
|
text = caption,
|
||||||
style = ElementRichTextEditorStyle.textStyle(),
|
style = ElementRichTextEditorStyle.textStyle(),
|
||||||
|
onLinkClickedListener = onLinkClick,
|
||||||
releaseOnDetach = false,
|
releaseOnDetach = false,
|
||||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||||
)
|
)
|
||||||
|
|
@ -128,7 +130,9 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
||||||
TimelineItemImageView(
|
TimelineItemImageView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = false,
|
hideMediaContent = false,
|
||||||
onShowClick = {},
|
onShowContentClick = {},
|
||||||
|
onContentClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +143,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||||
TimelineItemImageView(
|
TimelineItemImageView(
|
||||||
content = aTimelineItemImageContent(),
|
content = aTimelineItemImageContent(),
|
||||||
hideMediaContent = true,
|
hideMediaContent = true,
|
||||||
onShowClick = {},
|
onShowContentClick = {},
|
||||||
|
onContentClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
|
@ -25,9 +26,10 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemLocationView(
|
fun TimelineItemLocationView(
|
||||||
content: TimelineItemLocationContent,
|
content: TimelineItemLocationContent,
|
||||||
|
onContentClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.clickable(onClick = onContentClick).fillMaxWidth()) {
|
||||||
content.description?.let {
|
content.description?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
|
@ -51,5 +53,8 @@ fun TimelineItemLocationView(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
|
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
TimelineItemLocationView(content)
|
TimelineItemLocationView(
|
||||||
|
content = content,
|
||||||
|
onContentClick = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -40,6 +41,7 @@ private const val STICKER_SIZE_IN_DP = 128
|
||||||
fun TimelineItemStickerView(
|
fun TimelineItemStickerView(
|
||||||
content: TimelineItemStickerContent,
|
content: TimelineItemStickerContent,
|
||||||
hideMediaContent: Boolean,
|
hideMediaContent: Boolean,
|
||||||
|
onContentClick: () -> Unit,
|
||||||
onShowClick: () -> Unit,
|
onShowClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -61,7 +63,8 @@ fun TimelineItemStickerView(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
|
.clickable(onClick = onContentClick),
|
||||||
model = MediaRequestData(
|
model = MediaRequestData(
|
||||||
source = content.preferredMediaSource,
|
source = content.preferredMediaSource,
|
||||||
kind = MediaRequestData.Kind.File(
|
kind = MediaRequestData.Kind.File(
|
||||||
|
|
@ -85,6 +88,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
|
||||||
TimelineItemStickerView(
|
TimelineItemStickerView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = false,
|
hideMediaContent = false,
|
||||||
|
onContentClick = {},
|
||||||
onShowClick = {},
|
onShowClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -56,7 +57,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
|
||||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
|
||||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
@ -68,7 +68,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
fun TimelineItemVideoView(
|
fun TimelineItemVideoView(
|
||||||
content: TimelineItemVideoContent,
|
content: TimelineItemVideoContent,
|
||||||
hideMediaContent: Boolean,
|
hideMediaContent: Boolean,
|
||||||
onShowClick: () -> Unit,
|
onContentClick: () -> Unit,
|
||||||
|
onShowContentClick: () -> Unit,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -90,13 +92,14 @@ fun TimelineItemVideoView(
|
||||||
) {
|
) {
|
||||||
ProtectedView(
|
ProtectedView(
|
||||||
hideContent = hideMediaContent,
|
hideContent = hideMediaContent,
|
||||||
onShowClick = onShowClick,
|
onShowClick = onShowContentClick,
|
||||||
) {
|
) {
|
||||||
var isLoaded by remember { mutableStateOf(false) }
|
var isLoaded by remember { mutableStateOf(false) }
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
|
.clickable(onClick = onContentClick),
|
||||||
model = MediaRequestData(
|
model = MediaRequestData(
|
||||||
source = content.thumbnailSource,
|
source = content.thumbnailSource,
|
||||||
kind = MediaRequestData.Kind.Thumbnail(
|
kind = MediaRequestData.Kind.Thumbnail(
|
||||||
|
|
@ -128,9 +131,7 @@ fun TimelineItemVideoView(
|
||||||
val caption = if (LocalInspectionMode.current) {
|
val caption = if (LocalInspectionMode.current) {
|
||||||
SpannedString(content.caption)
|
SpannedString(content.caption)
|
||||||
} else {
|
} else {
|
||||||
content.formattedCaption?.body
|
content.formattedCaption ?: SpannedString(content.caption)
|
||||||
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
|
|
||||||
?: SpannedString(content.caption)
|
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||||
|
|
@ -142,6 +143,7 @@ fun TimelineItemVideoView(
|
||||||
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
||||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||||
text = caption,
|
text = caption,
|
||||||
|
onLinkClickedListener = onLinkClick,
|
||||||
style = ElementRichTextEditorStyle.textStyle(),
|
style = ElementRichTextEditorStyle.textStyle(),
|
||||||
releaseOnDetach = false,
|
releaseOnDetach = false,
|
||||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||||
|
|
@ -157,7 +159,9 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
||||||
TimelineItemVideoView(
|
TimelineItemVideoView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = false,
|
hideMediaContent = false,
|
||||||
onShowClick = {},
|
onShowContentClick = {},
|
||||||
|
onContentClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +172,9 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
||||||
TimelineItemVideoView(
|
TimelineItemVideoView(
|
||||||
content = aTimelineItemVideoContent(),
|
content = aTimelineItemVideoContent(),
|
||||||
hideMediaContent = true,
|
hideMediaContent = true,
|
||||||
onShowClick = {},
|
onShowContentClick = {},
|
||||||
|
onContentClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemImageContent(
|
TimelineItemImageContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -105,7 +105,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemStickerContent(
|
TimelineItemStickerContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -142,7 +142,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemVideoContent(
|
TimelineItemVideoContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
videoSource = messageType.source,
|
videoSource = messageType.source,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -161,7 +161,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemAudioContent(
|
TimelineItemAudioContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -176,7 +176,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -187,7 +187,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemAudioContent(
|
TimelineItemAudioContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -202,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemFileContent(
|
TimelineItemFileContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = messageType.formattedCaption,
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
fileSource = messageType.source,
|
fileSource = messageType.source,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
|
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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.EventId
|
||||||
|
|
@ -93,6 +95,17 @@ sealed interface TimelineItem {
|
||||||
|
|
||||||
val isRemote = eventId != null
|
val isRemote = eventId != null
|
||||||
|
|
||||||
|
/** Whether a click on any part of the event bubble should trigger the 'onContentClick' callback.
|
||||||
|
*
|
||||||
|
* This is `true` for all events except for visual media events with a caption or formatted caption.
|
||||||
|
*/
|
||||||
|
val isWholeContentClickable = when (content) {
|
||||||
|
is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
|
||||||
|
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
|
||||||
|
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
val eventOrTransactionId: EventOrTransactionId
|
val eventOrTransactionId: EventOrTransactionId
|
||||||
get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)
|
get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemAudioContent(
|
data class TimelineItemAudioContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface TimelineItemEventContent {
|
sealed interface TimelineItemEventContent {
|
||||||
|
|
@ -19,7 +18,7 @@ sealed interface TimelineItemEventContent {
|
||||||
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
|
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
|
||||||
val filename: String
|
val filename: String
|
||||||
val caption: String?
|
val caption: String?
|
||||||
val formattedCaption: FormattedBody?
|
val formattedCaption: CharSequence?
|
||||||
|
|
||||||
val bestDescription: String
|
val bestDescription: String
|
||||||
get() = caption ?: filename
|
get() = caption ?: filename
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||||
|
|
||||||
data class TimelineItemFileContent(
|
data class TimelineItemFileContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val fileSource: MediaSource,
|
val fileSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val formattedFileSize: String,
|
val formattedFileSize: String,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
@ -17,7 +16,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
data class TimelineItemImageContent(
|
data class TimelineItemImageContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val formattedFileSize: String,
|
val formattedFileSize: String,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
|
|
||||||
data class TimelineItemStickerContent(
|
data class TimelineItemStickerContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val formattedFileSize: String,
|
val formattedFileSize: String,
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemVideoContent(
|
data class TimelineItemVideoContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val videoSource: MediaSource,
|
val videoSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -17,7 +16,7 @@ data class TimelineItemVoiceContent(
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: FormattedBody?,
|
override val formattedCaption: CharSequence?,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
|
|
|
||||||
|
|
@ -529,7 +529,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onRoomDetailsClick = onRoomDetailsClick,
|
onRoomDetailsClick = onRoomDetailsClick,
|
||||||
onEventClick = onEventClick,
|
onEventContentClick = onEventClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onPreviewAttachments = onPreviewAttachments,
|
onPreviewAttachments = onPreviewAttachments,
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
onMessageClick = onMessageClick,
|
onContentClick = onMessageClick,
|
||||||
onMessageLongClick = onMessageLongClick,
|
onMessageLongClick = onMessageLongClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
filename = "body.mp4",
|
filename = "body.mp4",
|
||||||
caption = "body.mp4 caption",
|
caption = "body.mp4 caption",
|
||||||
formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
duration = 1.minutes,
|
duration = 1.minutes,
|
||||||
videoSource = MediaSource(url = "url", json = null),
|
videoSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
|
|
@ -527,7 +527,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
filename = "body.jpg",
|
filename = "body.jpg",
|
||||||
formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
caption = "body.jpg caption",
|
caption = "body.jpg caption",
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue