Merge branch 'develop' into feature/bma/sendImageFromKeyboard

This commit is contained in:
Benoit Marty 2024-01-08 08:59:00 +01:00 committed by GitHub
commit cbc86ea1e4
759 changed files with 2603 additions and 1574 deletions

View file

@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
@ -253,6 +254,23 @@ class MessagesFlowNode @AssistedInject constructor(
)
overlay.show(navTarget)
}
is TimelineItemStickerContent -> {
/* Sticker may have an empty url and no thumbnail
if encrypted on certain bridges */
if (event.content.preferredMediaSource != null) {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.preferredMediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
}
}
is TimelineItemVideoContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(

View file

@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -64,7 +65,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
@ -140,11 +141,11 @@ class MessagesPresenter @AssistedInject constructor(
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION_SENT, updateKey = syncUpdateFlow.value)
val roomName: Async<String> by remember {
derivedStateOf { roomInfo?.name?.let { Async.Success(it) } ?: Async.Uninitialized }
val roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
}
val roomAvatar: Async<AvatarData> by remember {
derivedStateOf { roomInfo?.avatarData()?.let { Async.Success(it) } ?: Async.Uninitialized }
val roomAvatar: AsyncData<AvatarData> by remember {
derivedStateOf { roomInfo?.avatarData()?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
}
var hasDismissedInviteDialog by rememberSaveable {
@ -161,7 +162,7 @@ class MessagesPresenter @AssistedInject constructor(
}
}
val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
withContext(dispatchers.io) {
@ -278,8 +279,8 @@ class MessagesPresenter @AssistedInject constructor(
.onFailure { Timber.e(it) }
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) {
inviteProgress.value = Async.Loading()
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
inviteProgress.value = AsyncData.Loading()
runCatching {
room.updateMembers()
@ -295,10 +296,10 @@ class MessagesPresenter @AssistedInject constructor(
}.getOrThrow()
}.fold(
onSuccess = {
inviteProgress.value = Async.Success(Unit)
inviteProgress.value = AsyncData.Success(Unit)
},
onFailure = {
inviteProgress.value = Async.Failure(it)
inviteProgress.value = AsyncData.Failure(it)
}
)
}
@ -351,6 +352,12 @@ class MessagesPresenter @AssistedInject constructor(
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemStickerContent -> AttachmentThumbnailInfo(
thumbnailSource = targetEvent.content.thumbnailSource ?: targetEvent.content.mediaSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,

View file

@ -25,7 +25,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@ -33,8 +33,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: Async<String>,
val roomAvatar: Async<AvatarData>,
val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>,
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToRedact: Boolean,
val userHasPermissionToSendReaction: Boolean,
@ -48,7 +48,7 @@ data class MessagesState(
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val inviteProgress: Async<Unit>,
val inviteProgress: AsyncData<Unit>,
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,

View file

@ -29,7 +29,7 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
@ -47,8 +47,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState().copy(userHasPermissionToSendMessage = false),
aMessagesState().copy(showReinvitePrompt = true),
aMessagesState().copy(
roomName = Async.Uninitialized,
roomAvatar = Async.Uninitialized,
roomName = AsyncData.Uninitialized,
roomAvatar = AsyncData.Uninitialized,
),
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
aMessagesState().copy(
@ -83,8 +83,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"),
roomName = Async.Success("Room name"),
roomAvatar = Async.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
roomName = AsyncData.Success("Room name"),
roomAvatar = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false,
userHasPermissionToSendReaction = true,
@ -117,7 +117,7 @@ fun aMessagesState() = MessagesState(
),
hasNetworkConnection = true,
snackbarMessage = null,
inviteProgress = Async.Uninitialized,
inviteProgress = AsyncData.Uninitialized,
showReinvitePrompt = false,
enableTextFormatting = true,
enableVoiceMessages = true,

View file

@ -152,7 +152,11 @@ class ActionListPresenter @Inject constructor(
add(TimelineItemAction.Reply)
}
}
add(TimelineItemAction.Forward)
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
if (!timelineItem.isSticker) {
add(TimelineItemAction.Forward)
}
}
if (timelineItem.isMine && timelineItem.isTextMessage) {
add(TimelineItemAction.Edit)

View file

@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -239,6 +240,9 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
is TimelineItemImageContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemVideoContent -> {
content = { ContentForBody(event.content.body) }
}

View file

@ -25,7 +25,7 @@ import androidx.compose.runtime.remember
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -48,7 +48,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
fun create(eventId: String): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = mutableStateOf(Async.Uninitialized)
private val forwardingActionState: MutableState<AsyncData<ImmutableList<RoomId>>> = mutableStateOf(AsyncData.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
matrixCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
@ -62,13 +62,13 @@ class ForwardMessagesPresenter @AssistedInject constructor(
fun handleEvents(event: ForwardMessagesEvents) {
when (event) {
ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncData.Uninitialized
}
}
return ForwardMessagesState(
isForwarding = forwardingActionState.value.isLoading(),
error = (forwardingActionState.value as? Async.Failure)?.error,
error = (forwardingActionState.value as? AsyncData.Failure)?.error,
forwardingSucceeded = forwardingSucceeded,
eventSink = { handleEvents(it) }
)
@ -77,12 +77,12 @@ class ForwardMessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.forwardEvent(
eventId: EventId,
roomIds: ImmutableList<RoomId>,
isForwardMessagesState: MutableState<Async<ImmutableList<RoomId>>>,
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
) = launch {
isForwardMessagesState.value = Async.Loading()
isForwardMessagesState.value = AsyncData.Loading()
room.forwardEvent(eventId, roomIds).fold(
{ isForwardMessagesState.value = Async.Success(roomIds) },
{ isForwardMessagesState.value = Async.Failure(it) }
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
{ isForwardMessagesState.value = AsyncData.Failure(it) }
)
}
}

View file

@ -27,7 +27,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -60,14 +60,14 @@ class ReportMessagePresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
var reason by rememberSaveable { mutableStateOf("") }
var blockUser by rememberSaveable { mutableStateOf(false) }
var result: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
var result: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvents(event: ReportMessageEvents) {
when (event) {
is ReportMessageEvents.UpdateReason -> reason = event.reason
ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser
ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result)
ReportMessageEvents.ClearError -> result.value = Async.Uninitialized
ReportMessageEvents.ClearError -> result.value = AsyncAction.Uninitialized
}
}
@ -84,7 +84,7 @@ class ReportMessagePresenter @AssistedInject constructor(
userId: UserId,
reason: String,
blockUser: Boolean,
result: MutableState<Async<Unit>>,
result: MutableState<AsyncAction<Unit>>,
) = launch {
result.runUpdatingState {
val userIdToBlock = userId.takeIf { blockUser }

View file

@ -16,11 +16,11 @@
package io.element.android.features.messages.impl.report
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
data class ReportMessageState(
val reason: String,
val blockUser: Boolean,
val result: Async<Unit>,
val result: AsyncAction<Unit>,
val eventSink: (ReportMessageEvents) -> Unit
)

View file

@ -17,7 +17,7 @@
package io.element.android.features.messages.impl.report
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageState> {
override val values: Sequence<ReportMessageState>
@ -25,9 +25,9 @@ open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageSt
aReportMessageState(),
aReportMessageState(reason = "This user is making the chat very toxic."),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable("error"))),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Loading),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(Throwable("error"))),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)),
// Add other states here
)
}
@ -35,7 +35,7 @@ open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageSt
fun aReportMessageState(
reason: String = "",
blockUser: Boolean = false,
result: Async<Unit> = Async.Uninitialized,
result: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = ReportMessageState(
reason = reason,
blockUser = blockUser,

View file

@ -41,8 +41,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -62,10 +62,10 @@ fun ReportMessageView(
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val isSending = state.result is Async.Loading
AsyncView(
val isSending = state.result is AsyncAction.Loading
AsyncActionView(
async = state.result,
showProgressDialog = false,
progressDialog = {},
onSuccess = { onBackClicked() },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) }

View file

@ -24,7 +24,9 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -41,6 +43,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
@ -52,7 +56,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@Composable
@OptIn(ExperimentalFoundationApi::class)
@ -114,8 +119,9 @@ sealed interface MessagesReactionsButtonContent {
val isHighlighted get() = this is Reaction && reaction.isHighlighted
}
private val reactionEmojiLineHeight = 20.sp
private val addEmojiSize = 16.dp
internal val REACTION_EMOJI_LINE_HEIGHT = 20.sp
internal const val REACTION_IMAGE_ASPECT_RATIO = 1.0f
private val ADD_EMOJI_SIZE = 16.dp
@Composable
private fun TextContent(
@ -123,7 +129,7 @@ private fun TextContent(
modifier: Modifier = Modifier,
) = Text(
modifier = modifier
.height(reactionEmojiLineHeight.toDp()),
.height(REACTION_EMOJI_LINE_HEIGHT.toDp()),
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.materialColors.primary
@ -138,7 +144,7 @@ private fun IconContent(
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
tint = ElementTheme.materialColors.secondary,
modifier = modifier
.size(addEmojiSize)
.size(ADD_EMOJI_SIZE)
)
@ -150,13 +156,25 @@ private fun ReactionContent(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Text(
text = reaction.displayKey,
style = ElementTheme.typography.fontBodyMdRegular.copy(
fontSize = 15.sp,
lineHeight = reactionEmojiLineHeight,
),
)
// Check if this is a custom reaction (MSC4027)
if (reaction.key.startsWith("mxc://")) {
AsyncImage(
modifier = modifier
.heightIn(min = REACTION_EMOJI_LINE_HEIGHT.toDp(), max = REACTION_EMOJI_LINE_HEIGHT.toDp())
.aspectRatio(REACTION_IMAGE_ASPECT_RATIO, false),
model = MediaRequestData(MediaSource(reaction.key), MediaRequestData.Kind.Content),
contentDescription = null
)
}
else {
Text(
text = reaction.displayKey,
style = ElementTheme.typography.fontBodyMdRegular.copy(
fontSize = 15.sp,
lineHeight = REACTION_EMOJI_LINE_HEIGHT,
),
)
}
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
@ -69,7 +70,8 @@ fun TimelineEventTimestampView(
Row(
modifier = Modifier
.then(clickModifier)
.padding(start = 16.dp) // Add extra padding for touch target size
// Add extra padding for touch target size
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
@ -107,3 +109,7 @@ internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEve
onLongClick = {},
)
}
object TimelineEventTimestampViewDefaults {
val spacing = 16.dp
}

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
@ -67,7 +68,8 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
@ -78,7 +80,9 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
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.features.messages.impl.timeline.model.event.canBeRepliedTo
@ -447,12 +451,13 @@ private fun MessageEventBubbleContent(
fun WithTimestampLayout(
timestampPosition: TimestampPosition,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
canShrinkContent: Boolean = false,
content: @Composable (onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
when (timestampPosition) {
TimestampPosition.Overlay ->
Box(modifier, contentAlignment = Alignment.Center) {
content()
content {}
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
@ -465,20 +470,26 @@ private fun MessageEventBubbleContent(
)
}
TimestampPosition.Aligned ->
Box(modifier) {
content()
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChanged) },
overlay = {
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
)
TimestampPosition.Below ->
Column(modifier) {
content()
content {}
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
@ -497,7 +508,8 @@ private fun MessageEventBubbleContent(
timestampPosition: TimestampPosition,
showThreadDecoration: Boolean,
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier
@ -514,7 +526,8 @@ private fun MessageEventBubbleContent(
}
timestampPosition != TimestampPosition.Overlay -> {
timestampLayoutModifier = Modifier
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
contentModifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
else -> {
timestampLayoutModifier = Modifier
@ -529,8 +542,9 @@ private fun MessageEventBubbleContent(
val contentWithTimestamp = @Composable {
WithTimestampLayout(
timestampPosition = timestampPosition,
canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier,
) {
) { onContentLayoutChanged ->
TimelineItemEventContentView(
content = event.content,
onLinkClicked = { url ->
@ -547,9 +561,9 @@ private fun MessageEventBubbleContent(
}
}
},
extraPadding = event.toExtraPadding(),
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChanged = onContentLayoutChanged,
modifier = contentModifier
)
}
}
@ -583,6 +597,7 @@ private fun MessageEventBubbleContent(
val timestampPosition = when (event.content) {
is TimelineItemImageContent,
is TimelineItemStickerContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
@ -592,6 +607,7 @@ private fun MessageEventBubbleContent(
showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition,
inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier
)
}
@ -653,7 +669,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
isMine = it,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
),

View file

@ -24,7 +24,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import org.jsoup.Jsoup
@PreviewsDayNight
@Composable
@ -38,18 +37,15 @@ internal fun TimelineItemEventRowTimestampPreview(
"Text longer, displayed on 1 line",
"Text which should be rendered on several lines",
).forEach { str ->
listOf(false, true).forEach { useDocument ->
ATimelineItemEventRow(
event = event.copy(
content = oldContent.copy(
body = str,
htmlDocument = if (useDocument) Jsoup.parse(str) else null,
),
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case",
ATimelineItemEventRow(
event = event.copy(
content = oldContent.copy(
body = str,
),
)
}
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = "A sender",
),
)
}
}
}

View file

@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
@ -109,6 +110,10 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
body = "Image",
type = ImageMessageType("Image", MediaSource("url"), null),
),
aMessageContent(
body = "Sticker",
type = StickerMessageType("Image", MediaSource("url"), null),
),
aMessageContent(
body = "File",
type = FileMessageType("File", MediaSource("url"), null),

View file

@ -32,7 +32,6 @@ import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
@ -81,7 +80,6 @@ fun TimelineItemStateEventRow(
TimelineItemEventContentView(
content = event.content,
onLinkClicked = {},
extraPadding = noExtraPadding,
eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding()
)

View file

@ -34,6 +34,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -44,15 +46,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(32.dp)
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
@ -65,7 +69,7 @@ fun TimelineItemAudioView(
.size(16.dp),
)
}
Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.body,
@ -75,11 +79,15 @@ fun TimelineItemAudioView(
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize + extraPadding.getStr(ElementTheme.typography.fontBodySmRegular),
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChanged = onContentLayoutChanged,
extraWidth = iconSize + spacing
)
)
}
}
@ -91,6 +99,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
ElementPreview {
TimelineItemAudioView(
content,
extraPadding = noExtraPadding,
onContentLayoutChanged = {},
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -29,14 +30,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemEncryptedView(
@Suppress("UNUSED_PARAMETER") content: TimelineItemEncryptedContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CommonDrawables.ic_waiting_to_decrypt,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
}
@ -48,6 +49,6 @@ internal fun TimelineItemEncryptedViewPreview() = ElementPreview {
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown
),
extraPadding = noExtraPadding
onContentLayoutChanged = {},
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -30,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -40,32 +42,32 @@ import io.element.android.libraries.architecture.Presenter
@Composable
fun TimelineItemEventContentView(
content: TimelineItemEventContent,
extraPadding: ExtraPadding,
onLinkClicked: (url: String) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {},
) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
extraPadding = extraPadding,
modifier = modifier,
onLinkClicked = onLinkClicked,
onContentLayoutChanged = onContentLayoutChanged
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemLocationContent -> TimelineItemLocationView(
@ -76,18 +78,22 @@ fun TimelineItemEventContentView(
content = content,
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
modifier = modifier
)
is TimelineItemFileContent -> TimelineItemFileView(
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView(
@ -104,7 +110,7 @@ fun TimelineItemEventContentView(
TimelineItemVoiceView(
state = presenter.present(),
content = content,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
}

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -44,15 +46,17 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun TimelineItemFileView(
content: TimelineItemFileContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(32.dp)
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
@ -66,7 +70,7 @@ fun TimelineItemFileView(
.rotate(-45f),
)
}
Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.body,
@ -76,11 +80,15 @@ fun TimelineItemFileView(
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize + extraPadding.getStr(textStyle = ElementTheme.typography.fontBodySmRegular),
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChanged = onContentLayoutChanged,
extraWidth = iconSize + spacing
)
)
}
}
@ -91,6 +99,6 @@ fun TimelineItemFileView(
internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = ElementPreview {
TimelineItemFileView(
content,
extraPadding = noExtraPadding,
onContentLayoutChanged = {},
)
}

View file

@ -25,9 +25,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -39,12 +41,19 @@ fun TimelineItemInformativeView(
text: String,
iconDescription: String,
@DrawableRes iconResourceId: Int,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
modifier = modifier.onSizeChanged { size ->
onContentLayoutChanged(
ContentAvoidingLayoutData(
contentWidth = size.width,
contentHeight = size.height,
)
)
},
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
resourceId = iconResourceId,
@ -57,7 +66,7 @@ fun TimelineItemInformativeView(
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodyMdRegular,
text = text + extraPadding.getStr(textStyle = ElementTheme.typography.fontBodyMdRegular)
text = text
)
}
}
@ -69,6 +78,6 @@ internal fun TimelineItemInformativeViewPreview() = ElementPreview {
text = "Info",
iconDescription = "",
iconResourceId = CompoundDrawables.ic_delete,
extraPadding = noExtraPadding,
onContentLayoutChanged = {},
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -28,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemRedactedView(
@Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_message_removed),
iconDescription = stringResource(id = CommonStrings.common_message_removed),
iconResourceId = CompoundDrawables.ic_delete,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
}
@ -45,6 +46,6 @@ fun TimelineItemRedactedView(
internal fun TimelineItemRedactedViewPreview() = ElementPreview {
TimelineItemRedactedView(
TimelineItemRedactedContent,
extraPadding = noExtraPadding
onContentLayoutChanged = {},
)
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
private const val STICKER_SIZE_IN_DP = 128
private const val DEFAULT_ASPECT_RATIO = 1.33f
@Composable
fun TimelineItemStickerView(
content: TimelineItemStickerContent,
modifier: Modifier = Modifier,
) {
val safeAspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
Box(
modifier = modifier
.heightIn(min = STICKER_SIZE_IN_DP.dp, max = STICKER_SIZE_IN_DP.dp)
.aspectRatio(safeAspectRatio, false),
contentAlignment = Alignment.TopStart,
) {
BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurhash,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview {
TimelineItemStickerView(content)
}

View file

@ -22,12 +22,11 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.core.text.buildSpannedString
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -38,9 +37,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
extraPadding: ExtraPadding,
onLinkClicked: (String) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {},
) {
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
@ -49,19 +48,14 @@ fun TimelineItemTextView(
val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body)
val extraPaddingText = extraPadding.getStr()
Box(modifier) {
val textWithPadding = remember(body) {
buildSpannedString {
append(body)
append(extraPaddingText)
}
}
EditorStyledText(
text = textWithPadding,
text = body,
onLinkClickedListener = onLinkClicked,
style = ElementRichTextEditorStyle.textStyle(),
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
releaseOnDetach = false,
)
}
}
@ -74,7 +68,6 @@ internal fun TimelineItemTextViewPreview(
) = ElementPreview {
TimelineItemTextView(
content = content,
extraPadding = ExtraPadding(extraWidth = 32.dp),
onLinkClicked = {},
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -28,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemUnknownView(
@Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CompoundDrawables.ic_info_solid,
extraPadding = extraPadding,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
}
@ -45,6 +46,6 @@ fun TimelineItemUnknownView(
internal fun TimelineItemUnknownViewPreview() = ElementPreview {
TimelineItemUnknownView(
content = TimelineItemUnknownContent,
extraPadding = noExtraPadding
onContentLayoutChanged = {},
)
}

View file

@ -34,6 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@ -43,6 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
@ -64,7 +66,7 @@ import kotlinx.coroutines.delay
fun TimelineItemVoiceView(
state: VoiceMessageState,
content: TimelineItemVoiceContent,
extraPadding: ExtraPadding,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
fun playPause() {
@ -73,9 +75,18 @@ fun TimelineItemVoiceView(
val a11y = stringResource(CommonStrings.common_voice_message)
Row(
modifier = modifier.semantics {
contentDescription = a11y
},
modifier = modifier
.semantics {
contentDescription = a11y
}
.onSizeChanged {
onContentLayoutChanged(
ContentAvoidingLayoutData(
contentWidth = it.width,
contentHeight = it.height,
)
)
},
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
@ -105,7 +116,6 @@ fun TimelineItemVoiceView(
seekEnabled = !context.isScreenReaderEnabled(),
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
)
Spacer(Modifier.width(extraPadding.getDpSize()))
}
}
@ -237,7 +247,7 @@ internal fun TimelineItemVoiceViewPreview(
TimelineItemVoiceView(
state = timelineItemVoiceViewParameters.state,
content = timelineItemVoiceViewParameters.content,
extraPadding = noExtraPadding,
onContentLayoutChanged = {},
)
}
@ -250,7 +260,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
TimelineItemVoiceView(
state = it.state,
content = it.content,
extraPadding = noExtraPadding,
onContentLayoutChanged = {},
)
}
}

View file

@ -0,0 +1,205 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.layout
import android.text.Layout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A layout with 2 children: the [content] and the [overlay].
*
* It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping.
* If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row.
*
* @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content].
* @param modifier The modifier for the layout.
* @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`.
* @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content].
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
* @param content The 'content' component of the layout.
*/
@Composable
fun ContentAvoidingLayout(
overlay: @Composable () -> Unit,
modifier: Modifier = Modifier,
spacing: Dp = 0.dp,
overlayOffset: DpOffset = DpOffset.Zero,
shrinkContent: Boolean = false,
content: @Composable ContentAvoidingLayoutScope.() -> Unit,
) {
val scope = remember { ContentAvoidingLayoutScopeInstance() }
SubcomposeLayout(
modifier = modifier,
) { constraints ->
// Measure the `overlay` view first, in case we need to shrink the `content`
val overlayPlaceable = subcompose(0, overlay).first().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
val contentConstraints = if (shrinkContent) {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
} else {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
}
val contentPlaceable = subcompose(1) { scope.content() }.first().measure(contentConstraints)
var layoutWidth = contentPlaceable.width
var layoutHeight = contentPlaceable.height
val data = scope.data
// Free space = width of the whole component - width of its non overlapping contents
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
when {
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
!shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx()
}
// If the content is smaller than the available max width, we can move the overlay to the right of the content
contentPlaceable.width < constraints.maxWidth -> {
// If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it
if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) {
// Otherwise, we need to increase it by the width of the overlay + some padding adjustments
val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width)
layoutWidth = min(calculatedWidth, constraints.maxWidth)
}
}
else -> Unit
}
layoutWidth = max(layoutWidth, constraints.minWidth)
layoutHeight = max(layoutHeight, constraints.minHeight)
layout(layoutWidth, layoutHeight) {
contentPlaceable.placeRelative(0, 0)
overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx())
}
}
}
/**
* Data class to hold the content layout data.
* This is used to pass the data from the content to the [ContentAvoidingLayout].
*
* @param contentWidth The full width of the content in pixels.
* @param contentHeight The full height of the content in pixels.
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/
@Suppress("DataClassShouldBeImmutable")
data class ContentAvoidingLayoutData(
var contentWidth: Int = 0,
var contentHeight: Int = 0,
var nonOverlappingContentWidth: Int = contentWidth,
var nonOverlappingContentHeight: Int = contentHeight,
)
/**
* A scope for the [ContentAvoidingLayout].
*/
interface ContentAvoidingLayoutScope {
/**
* It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly.
*/
fun onContentLayoutChanged(data: ContentAvoidingLayoutData)
}
private class ContentAvoidingLayoutScopeInstance(
val data: ContentAvoidingLayoutData = ContentAvoidingLayoutData(),
) : ContentAvoidingLayoutScope {
override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) {
this.data.contentWidth = data.contentWidth
this.data.contentHeight = data.contentHeight
this.data.nonOverlappingContentWidth = data.nonOverlappingContentWidth
this.data.nonOverlappingContentHeight = data.nonOverlappingContentHeight
}
}
object ContentAvoidingLayout {
/**
* Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
*/
@Composable
internal fun measureLastTextLine(
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) {
val layoutDirection = LocalLayoutDirection.current
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = when (layoutDirection) {
LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
}
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChanged(
ContentAvoidingLayoutData(
contentWidth = textLayout.size.width + extraWidthPx,
contentHeight = textLayout.size.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
/**
* Measures the last line of a [Layout] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
*/
@Composable
internal fun measureLegacyLastTextLine(
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((Layout) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: Layout ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt()
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1)
onContentLayoutChanged(
ContentAvoidingLayoutData(
contentWidth = textLayout.width + extraWidthPx,
contentHeight = textLayout.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@ -57,20 +58,27 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.compound.theme.ElementTheme
import kotlinx.coroutines.launch
internal val REACTION_SUMMARY_LINE_HEIGHT = 25.sp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReactionSummaryView(
@ -192,13 +200,25 @@ private fun AggregatedReactionButton(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = reaction.displayKey,
style = ElementTheme.typography.fontBodyMdRegular.copy(
fontSize = 20.sp,
lineHeight = 25.sp
),
)
// Check if this is a custom reaction (MSC4027)
if (reaction.key.startsWith("mxc://")) {
AsyncImage(
modifier = Modifier
.heightIn(min = REACTION_SUMMARY_LINE_HEIGHT.toDp(), max = REACTION_SUMMARY_LINE_HEIGHT.toDp())
.aspectRatio(REACTION_IMAGE_ASPECT_RATIO, false),
model = MediaRequestData(MediaSource(reaction.key), MediaRequestData.Kind.Content),
contentDescription = null
)
}
else {
Text(
text = reaction.displayKey,
style = ElementTheme.typography.fontBodyMdRegular.copy(
fontSize = 20.sp,
lineHeight = REACTION_SUMMARY_LINE_HEIGHT
),
)
}
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(
@ -206,7 +226,7 @@ private fun AggregatedReactionButton(
color = textColor,
style = ElementTheme.typography.fontBodyMdRegular.copy(
fontSize = 20.sp,
lineHeight = 25.sp
lineHeight = REACTION_SUMMARY_LINE_HEIGHT
)
)
}

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
@ -50,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
@ -70,7 +72,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisplayName ${messageType.body}"
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
@ -81,7 +83,22 @@ class TimelineItemContentMessageFactory @Inject constructor(
is ImageMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
body = messageType.body,
body = messageType.body.trimEnd(),
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is StickerMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
body = messageType.body.trimEnd(),
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -96,16 +113,17 @@ class TimelineItemContentMessageFactory @Inject constructor(
is LocationMessageType -> {
val location = Location.fromGeoUri(messageType.geoUri)
if (location == null) {
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = messageType.body,
body = body,
htmlDocument = null,
plainText = messageType.body,
plainText = body,
formattedBody = null,
isEdited = content.isEdited,
)
} else {
TimelineItemLocationContent(
body = messageType.body,
body = messageType.body.trimEnd(),
location = location,
description = messageType.description
)
@ -114,7 +132,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is VideoMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
body = messageType.body,
body = messageType.body.trimEnd(),
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -129,7 +147,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
is AudioMessageType -> {
TimelineItemAudioContent(
body = messageType.body,
body = messageType.body.trimEnd(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -142,7 +160,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
true -> {
TimelineItemVoiceContent(
eventId = eventId,
body = messageType.body,
body = messageType.body.trimEnd(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -151,7 +169,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
false -> {
TimelineItemAudioContent(
body = messageType.body,
body = messageType.body.trimEnd(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -164,7 +182,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
TimelineItemFileContent(
body = messageType.body,
body = messageType.body.trimEnd(),
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
@ -172,26 +190,33 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtension
)
}
is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted) ?: messageType.body.withLinks(),
isEdited = content.isEdited,
)
is TextMessageType -> {
TimelineItemTextContent(
body = messageType.body,
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
TimelineItemNoticeContent(
body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted) ?: messageType.body.withLinks(),
formattedBody = parseHtml(messageType.formatted) ?:body.withLinks(),
isEdited = content.isEdited,
)
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited,
)
}
is OtherMessageType -> {
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
htmlDocument = null,
formattedBody = body.withLinks(),
isEdited = content.isEdited,
)
}
is OtherMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = null,
formattedBody = messageType.body.withLinks(),
isEdited = content.isEdited,
)
}
}
@ -208,7 +233,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body)
.fromHtmlToSpans(formattedBody.body.trimEnd())
.withFixedURLSpans()
return if (prefix != null) {
buildSpannedString {

View file

@ -17,13 +17,43 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import javax.inject.Inject
class TimelineItemContentStickerFactory @Inject constructor() {
class TimelineItemContentStickerFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor
) {
private fun aspectRatioOf(width: Long?, height: Long?): Float? {
val result = if (height != null && width != null) {
width.toFloat() / height.toFloat()
} else {
null
}
fun create(@Suppress("UNUSED_PARAMETER") content: StickerContent): TimelineItemEventContent {
return TimelineItemUnknownContent
return result?.takeIf { it.isFinite() }
}
fun create(content: StickerContent): TimelineItemEventContent {
val aspectRatio = aspectRatioOf(content.info.width, content.info.height)
return TimelineItemStickerContent(
body = content.body,
mediaSource = MediaSource(content.url),
thumbnailSource = content.info.thumbnailSource,
mimeType = content.info.mimetype ?: MimeTypes.OctetStream,
blurhash = content.info.blurhash,
width = content.info.width?.toInt(),
height = content.info.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(content.info.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(content.body)
)
}
}

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -54,6 +55,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
is TimelineItemStickerContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
is TimelineItemAudioContent,

View file

@ -21,6 +21,7 @@ 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.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
@ -45,6 +46,10 @@ fun InReplyTo.map() = when (this) {
val messageContent = content as MessageContent
(messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body
}
is StickerContent -> {
val stickerContent = content as StickerContent
stickerContent.body
}
else -> null
}
)

View file

@ -19,12 +19,14 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
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.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -98,6 +100,13 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
}
is StickerContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = MediaSource(eventContent.url),
textContent = eventContent.body,
type = AttachmentThumbnailType.Image
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
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.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -81,6 +82,8 @@ sealed interface TimelineItem {
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
val isSticker: Boolean = content is TimelineItemStickerContent
val isRemote = eventId != null
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import kotlin.time.Duration
data class TimelineItemAudioContent(
@ -29,7 +30,7 @@ data class TimelineItemAudioContent(
) : TimelineItemEventContent {
val fileExtensionAndSize =
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
formatFileExtensionAndSize(
fileExtension,
formattedFileSize
)

View file

@ -56,6 +56,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemEncryptedContent,
is TimelineItemFileContent,
is TimelineItemImageContent,
is TimelineItemStickerContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemStickerContent(
val body: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,
val aspectRatio: Float?
) : TimelineItemEventContent {
override val type: String = "TimelineItemStickerContent"
/* Stickers are supposed to be small images so
we allow using the mediaSource (unless the url is empty) */
val preferredMediaSource = if (mediaSource.url.isEmpty()) thumbnailSource else mediaSource
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.media3.common.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
open class TimelineItemStickerContentProvider : PreviewParameterProvider<TimelineItemStickerContent> {
override val values: Sequence<TimelineItemStickerContent>
get() = sequenceOf(
aTimelineItemStickerContent(),
aTimelineItemStickerContent().copy(aspectRatio = 1.0f),
aTimelineItemStickerContent().copy(aspectRatio = 1.5f),
)
}
fun aTimelineItemStickerContent() = TimelineItemStickerContent(
body = "a body",
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,
blurhash = A_BLUR_HASH,
width = null,
height = 128,
aspectRatio = 0.5f,
formattedFileSize = "4MB",
fileExtension = "jpg"
)

View file

@ -28,6 +28,7 @@ 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.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -41,6 +42,12 @@ import javax.inject.Inject
class MessageSummaryFormatterImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : MessageSummaryFormatter {
companion object {
// Max characters to display in the summary message. This works around https://github.com/element-hq/element-x-android/issues/2105
private const val MAX_SAFE_LENGTH = 500
}
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.plainText
@ -53,9 +60,10 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemStickerContent -> context.getString(CommonStrings.common_sticker)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
}
}.take(MAX_SAFE_LENGTH)
}
}

View file

@ -33,7 +33,7 @@ import io.element.android.features.messages.impl.timeline.di.TimelineItemEventCo
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.di.RoomScope
@ -71,7 +71,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
body = content.body,
)
private val play = mutableStateOf<Async<Unit>>(Async.Uninitialized)
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
@Composable
override fun present(): VoiceMessageState {
@ -91,8 +91,8 @@ class VoiceMessagePresenter @AssistedInject constructor(
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is Async.Loading -> VoiceMessageState.Button.Downloading
play.value is Async.Failure -> VoiceMessageState.Button.Retry
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
}
}

View file

@ -34,10 +34,10 @@
<string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string>
<string name="screen_room_message_copied">"Nachricht wurde kopiert"</string>
<string name="screen_room_no_permission_to_post">"Du bist nicht berechtigt, in diesem Raum zu posten"</string>
<string name="screen_room_notification_settings_allow_custom">"Benutzerdefinierte Einstellung zulassen"</string>
<string name="screen_room_notification_settings_allow_custom">"Benutzerdefinierte Einstellungen verwenden"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Wenn du diese Option aktivierst, wird deine Standardeinstellung außer Kraft gesetzt."</string>
<string name="screen_room_notification_settings_custom_settings_title">"Benachrichtige mich in diesem Chat bei"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Du kannst das in deinem %1$s ändern."</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Zum Anpassen der Standardeinstellungen gehe zu: %1$s"</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Globale Einstellungen"</string>
<string name="screen_room_notification_settings_default_setting_title">"Standardeinstellung"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Benutzerdefinierte Einstellung entfernen"</string>
@ -55,7 +55,7 @@
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_voice_message_tooltip">"Zum Aufnehmen gedrückt halten"</string>
<string name="screen_room_mentions_at_room_title">"Alle"</string>
<string name="screen_report_content_block_user">"Benutzer sperren"</string>
<string name="screen_report_content_block_user">"Benutzer blockieren"</string>
<string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
</resources>

View file

@ -51,7 +51,7 @@ import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -117,14 +117,14 @@ class MessagesPresenterTest {
}.test {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(Async.Success(""))
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomAvatar)
.isEqualTo(Async.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue()
assertThat(initialState.userHasPermissionToRedact).isFalse()
assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(Async.Uninitialized)
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.showReinvitePrompt).isFalse()
}
}

View file

@ -59,7 +59,10 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
htmlConverterProvider = FakeHtmlConverterProvider(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
stickerFactory = TimelineItemContentStickerFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
),
pollFactory = TimelineItemContentPollFactory(FakeFeatureFlagService(), FakePollContentStateFactory()),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),

View file

@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -45,7 +45,7 @@ class ReportMessagePresenterTests {
val initialState = awaitItem()
assertThat(initialState.reason).isEmpty()
assertThat(initialState.blockUser).isFalse()
assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java)
assertThat(initialState.result).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@ -91,8 +91,8 @@ class ReportMessagePresenterTests {
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
skipItems(1)
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
}
}
@ -106,8 +106,8 @@ class ReportMessagePresenterTests {
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
}
}
@ -123,13 +123,13 @@ class ReportMessagePresenterTests {
}.test {
val initialState = awaitItem()
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
val resultState = awaitItem()
assertThat(resultState.result).isInstanceOf(Async.Failure::class.java)
assertThat(resultState.result).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(room.reportedContentCount).isEqualTo(1)
resultState.eventSink(ReportMessageEvents.ClearError)
assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
@ -438,6 +440,30 @@ class TimelineItemContentMessageFactoryTest {
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create StickerMessageType`() = runTest {
val sut = createTimelineItemContentStickerFactory()
val result = sut.create(
content = createStickerContent(
"body",
ImageInfo(32, 32, "image/webp", 8192, null, MediaSource("thumbnail://url"), null),
"url")
)
val expected = TimelineItemStickerContent(
body = "body",
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource(url = "thumbnail://url", json = null),
formattedFileSize = "8192 Bytes",
fileExtension = "",
mimeType = MimeTypes.WebP,
blurhash = null,
width = 32,
height = 32,
aspectRatio = 1.0f
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create ImageMessageType with info`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -627,4 +653,20 @@ class TimelineItemContentMessageFactoryTest {
featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
)
private fun createStickerContent(
body: String = "Body",
inImageInfo: ImageInfo,
inUrl: String
): StickerContent {
return StickerContent (
body = body,
info = inImageInfo,
url = inUrl
)
}
private fun createTimelineItemContentStickerFactory() = TimelineItemContentStickerFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
)
}