Merge branch 'develop' into feature/bma/sendImageFromKeyboard
This commit is contained in:
commit
cbc86ea1e4
759 changed files with 2603 additions and 1574 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
|
|||
is TimelineItemEncryptedContent,
|
||||
is TimelineItemFileContent,
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemPollContent,
|
||||
is TimelineItemVoiceContent,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue