Merge branch 'develop' into misc/reactions-pinned-messages

This commit is contained in:
Benoit Marty
2026-08-13 09:32:25 +02:00
committed by GitHub
2312 changed files with 23412 additions and 8843 deletions
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
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.blurHash
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
@@ -155,6 +156,7 @@ class MessagesFlowNode(
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget
@Parcelize
@@ -377,6 +379,7 @@ class MessagesFlowNode(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
@@ -828,6 +831,7 @@ class MessagesFlowNode(
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
)
}.reversed()
NavTarget.GalleryViewer(
@@ -861,6 +865,7 @@ class MessagesFlowNode(
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
blurHash = null,
)
}.reversed()
NavTarget.GalleryViewer(
@@ -920,6 +925,7 @@ class MessagesFlowNode(
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
blurHash = content.blurHash(),
)
}
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +55,7 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -66,6 +68,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
@@ -97,6 +101,8 @@ class MessagesNode(
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val focusedEventId: EventId?,
@@ -261,6 +267,7 @@ class MessagesNode(
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) {
val state = presenter.present()
@@ -325,6 +332,15 @@ class MessagesNode(
onViewRequestsClick = callback::navigateToKnockRequestsList,
)
},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = callback::navigateToThreadsList,
)
roomMemberModerationRenderer.Render(
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.toThreadId
@@ -83,9 +84,9 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@@ -319,6 +320,7 @@ class MessagesPresenter(
appName = buildMeta.applicationName,
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
dmUserStatus = roomInfo.dmUserStatus(),
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.ImmutableList
data class MessagesState(
@@ -53,6 +54,7 @@ data class MessagesState(
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val dmUserStatus: DisplayedStatus?,
val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon,
@@ -50,10 +50,10 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -129,6 +129,7 @@ fun aMessagesState(
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -159,6 +160,7 @@ fun aMessagesState(
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventSink = eventSink,
)
@@ -193,11 +195,9 @@ fun aReactionSummaryState(
fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState(
target = target,
recentEmojis = recentEmojis,
selectedEmoji = persistentSetOf(),
eventSink = eventSink,
)
@@ -20,14 +20,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
@@ -81,7 +84,6 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
@@ -113,6 +115,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
@@ -123,6 +126,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@@ -144,10 +149,13 @@ fun MessagesView(
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onThreadsListClick: () -> Unit,
knockRequestsBannerView: @Composable () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit,
customReactionBottomSheet: @Composable () -> Unit,
) {
val eventContentValidationState = LocalEventContentValidationState.current
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
@@ -170,6 +178,9 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
localView.hideKeyboard()
@@ -219,7 +230,7 @@ fun MessagesView(
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
contentWindowInsets = scaffoldScrollableContentInsets,
topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(
@@ -237,6 +248,7 @@ fun MessagesView(
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
@@ -294,6 +306,7 @@ fun MessagesView(
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
},
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
@@ -402,12 +415,7 @@ fun MessagesView(
},
)
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
}
)
customReactionBottomSheet()
ReactionSummaryView(state = state.reactionSummaryState)
ReadReceiptBottomSheet(
@@ -474,6 +482,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
@@ -531,6 +540,7 @@ private fun MessagesViewContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = topBannersHeightDp,
@@ -576,12 +586,17 @@ private fun MessagesViewComposerBottomSheetContents(
onRoomSuccessorClick: (RoomId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
when {
state.successorRoom != null -> {
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick)
SuccessorRoomBanner(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
}
state.userEventPermissions.canSendMessage -> {
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -605,15 +620,15 @@ private fun MessagesViewComposerBottomSheetContents(
}
}
else -> {
CantSendMessageBanner()
CantSendMessageBanner(Modifier.padding(contentPadding))
}
}
}
@Composable
private fun CantSendMessageBanner() {
private fun CantSendMessageBanner(modifier: Modifier = Modifier) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
@@ -662,6 +677,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -719,5 +735,6 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onThreadsListClick = {},
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
)
}
@@ -44,13 +44,13 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -248,7 +248,7 @@ private fun ActionListViewContent(
modifier = Modifier.clickable {
onActionClick(action)
},
headlineContent = {
content = {
Text(text = stringResource(id = action.titleRes))
},
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
@@ -461,7 +461,7 @@ private fun VerifiedUserSendFailureView(
.padding(horizontal = 16.dp, vertical = 8.dp),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
content = {
Text(
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
@@ -494,7 +494,7 @@ private fun VideoQualitySelectorDialog(
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
val title = "${preset.title()} ($estimationMb)"
ListItem(
headlineContent = {
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
@@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -106,22 +106,22 @@ private fun AttachmentSourcePickerMenu(
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
)
if (state.canShareLocation) {
ListItem(
@@ -130,7 +130,7 @@ private fun AttachmentSourcePickerMenu(
onSendLocationClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
ListItem(
@@ -139,13 +139,13 @@ private fun AttachmentSourcePickerMenu(
onCreatePollClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
content = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
)
}
}
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
@@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -288,8 +295,7 @@ class MessageComposerPresenter(
inReplyToEventId = inReplyToEventId,
)
// Reset composer since the attachment has been sent
messageComposerContext.composerMode = MessageComposerMode.Normal
resetComposerModeAfterAttaching()
}
is MessageComposerEvent.SetMode -> {
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@@ -419,6 +425,7 @@ class MessageComposerPresenter(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode,
isInThreadTimeline = isInThread,
showAttachmentSourcePicker = showAttachmentSourcePicker,
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
@@ -638,8 +645,7 @@ class MessageComposerPresenter(
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
// Reset composer since the attachment will be sent in a separate flow
messageComposerContext.composerMode = MessageComposerMode.Normal
resetComposerModeAfterAttaching()
}
private fun handlePickedMediaList(
@@ -663,7 +669,15 @@ class MessageComposerPresenter(
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
messageComposerContext.composerMode = MessageComposerMode.Normal
resetComposerModeAfterAttaching()
}
private fun resetComposerModeAfterAttaching() {
// An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset.
// An in-progress edit must survive: the typed text still edits the original on the next send.
if (!messageComposerContext.composerMode.isEditing) {
messageComposerContext.composerMode = MessageComposerMode.Normal
}
}
private suspend fun sendMedia(
@@ -842,6 +856,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
}
else -> {
if (currentComposerMode is MessageComposerMode.Reply) {
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
contentScannerService.scan(mediaSources, contentValidationState)
}
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
@@ -21,6 +21,7 @@ data class MessageComposerState(
val textEditorState: TextEditorState,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val isInThreadTimeline: Boolean,
val showAttachmentSourcePicker: Boolean,
val showTextFormatting: Boolean,
val canShareLocation: Boolean,
@@ -29,6 +29,7 @@ fun aMessageComposerState(
textEditorState: TextEditorState = aTextEditorStateRich(),
isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal,
isInThreadTimeline: Boolean = false,
showTextFormatting: Boolean = false,
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
@@ -39,6 +40,7 @@ fun aMessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen,
mode = mode,
isInThreadTimeline = isInThreadTimeline,
showTextFormatting = showTextFormatting,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
@@ -102,6 +102,7 @@ internal fun MessageComposerView(
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
composerMode = state.mode,
isInThreadTimeline = state.isInThreadTimeline,
showTextFormatting = state.showTextFormatting,
onResetComposerMode = ::onCloseSpecialMode,
onAddAttachment = ::onAddAttachment,
@@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@@ -112,12 +114,16 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null
}
val title = when (suggestion) {
val title: String? = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Member -> suggestion.roomMember.getBestName()
is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command
}
val status = when (suggestion) {
is ResolvedSuggestion.Member -> suggestion.roomMember.displayedStatus
else -> null
}
val details = when (suggestion) {
is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member,
@@ -148,11 +154,11 @@ private fun SuggestionItemView(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
title?.let {
Text(
text = it,
DisplayNameWithStatus(
name = title,
status = status,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
nameColor = ElementTheme.colors.textPrimary,
)
}
details?.let {
@@ -191,6 +197,7 @@ internal fun SuggestionsPickerViewPreview() {
role = RoomMember.Role.User,
membershipChangeReason = null,
isServiceMember = false,
displayedStatus = null,
)
val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView(
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.pinned
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
internal fun List<MatrixTimelineItem>.keepDisplayablePinnedEvents(): List<MatrixTimelineItem> {
val items = filter { it.isDisplayableAsPinnedEvent() }
return items.filterIndexed { index, item ->
!item.isDayDivider() || items.getOrNull(index + 1) is MatrixTimelineItem.Event
}
}
internal fun EventContent.isDisplayableAsPinnedEvent(): Boolean {
return when (this) {
UnknownContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent -> false
else -> true
}
}
private fun MatrixTimelineItem.isDisplayableAsPinnedEvent(): Boolean {
return this !is MatrixTimelineItem.Event || event.content.isDisplayableAsPinnedEvent()
}
private fun MatrixTimelineItem.isDayDivider(): Boolean {
return this is MatrixTimelineItem.Virtual && virtual is VirtualTimelineItem.DayDivider
}
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.pinned.isDisplayableAsPinnedEvent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -24,6 +25,7 @@ class PinnedMessagesBannerItemFactory(
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null
if (!timelineItem.event.content.isDisplayableAsPinnedEvent()) return@withContext null
val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
@@ -16,10 +16,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -98,7 +103,8 @@ private fun PinnedMessagesBannerRow(
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvent.MoveToNextPinned)
}
},
}
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.width(26.dp))
@@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@@ -225,7 +226,7 @@ class PinnedMessagesListPresenter(
val timelineItemsFlow = asyncTimeline.data.timelineItems
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
timelineItems = items.keepDisplayablePinnedEvents(),
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false,
)
@@ -259,7 +260,7 @@ class PinnedMessagesListPresenter(
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
is AsyncData.Failure -> PinnedMessagesListState.Failed
is AsyncData.Success -> {
if (timelineItems.data.isEmpty()) {
if (timelineItems.data.all { it is TimelineItem.Virtual }) {
PinnedMessagesListState.Empty
} else {
val actionListState = actionListPresenter.present()
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -40,7 +41,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -52,6 +52,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -95,7 +97,8 @@ fun PinnedMessagesListView(
.padding(padding)
.consumeWindowInsets(padding),
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
)
}
@@ -225,11 +228,12 @@ private fun PinnedMessagesListLoaded(
onEmojiReactionClick = ::onReactionClick,
onVerifiedUserSendFailureClick = {}
)
LazyColumn(
modifier = modifier.fillMaxSize(),
state = rememberLazyListState(),
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
contentPadding = lazyColumnContentPadding + PaddingValues(vertical = 8.dp),
) {
items(
items = state.timelineItems,
@@ -258,6 +262,7 @@ private fun PinnedMessagesListLoaded(
onMoreReactionsClick = ::onMoreReactionsClick,
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
eventSink = { timelineItemEvent ->
when (timelineItemEvent) {
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
@@ -315,9 +320,9 @@ private fun TimelineItemEventContentViewWrapper(
)
} else {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesEvent
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val threadRootEventId: ThreadId,
@@ -322,6 +326,15 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = {},
)
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -54,6 +55,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -129,14 +132,15 @@ fun ThreadsListView(
},
navigationIcon = {
BackButton(onBackClick)
}
},
windowInsets = scaffoldScrollableContentInsets,
)
}
) { padding ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
contentPadding = padding + lazyColumnContentPadding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
@@ -383,7 +387,12 @@ fun aThreadListItem(
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = USER_NAME_ALICE, displayNameAmbiguous = false, avatarUrl = null),
senderProfile: ProfileDetails = ProfileDetails.Ready(
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",
@@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration
sealed interface TimelineEvent {
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent
data class ValidateMedia(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState
) : TimelineItemEvent
}
@@ -35,6 +35,8 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -64,6 +66,7 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
@@ -98,6 +101,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -157,6 +161,8 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) {
when (event) {
is TimelineEvent.LoadMore -> {
@@ -252,6 +258,9 @@ class TimelinePresenter(
focusedEventId = event.focusedEvent,
)
}
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
}
}
@@ -259,7 +268,7 @@ class TimelinePresenter(
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems
timelineItems = newTimelineItems.toImmutableList()
analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems)
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemRead
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
@@ -255,6 +256,23 @@ internal fun aGroupedEvents(
)
}
internal fun aRedactedMessagesGroupedEvents(
id: UniqueId = UniqueId("redacted_group"),
count: Int = 4,
): TimelineItem.GroupedEvents {
val events = (0 until count).map { index ->
aTimelineItemEvent(
eventId = EventId("\$redacted_$index"),
content = TimelineItemRedactedContent,
)
}
return TimelineItem.GroupedEvents(
id = id,
events = events.toImmutableList(),
aggregatedReadReceipts = persistentListOf(),
)
}
internal fun aTimelineRoomInfo(
name: String = ROOM_NAME,
isDm: Boolean = false,
@@ -27,9 +27,15 @@ 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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -130,6 +136,7 @@ fun TimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
@@ -192,7 +199,7 @@ fun TimelineView(
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = true,
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
) {
items(
items = state.timelineItems,
@@ -219,6 +226,7 @@ fun TimelineView(
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
}
@@ -599,6 +607,7 @@ internal fun TimelineViewPreview(
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
@@ -649,6 +658,7 @@ private fun TimelineViewWithReadMarker(
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true,
onJoinCallClick = { },
)
}
}
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
@@ -0,0 +1,378 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
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
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
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.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun ActiveCallTimelineItemView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
state: RtcNotificationState.Active,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractivePrimary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp)
.size(40.dp)
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(8.dp))
.padding(10.dp),
imageVector = if (state.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
if (timelineRoomInfo.isDm) {
DirectMessageCallBody(state, Modifier.weight(1f))
} else {
GroupCallBody(
event,
state,
Modifier
.weight(1f)
.padding(top = 12.dp, bottom = 12.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp)
) {
if (!state.isJoined) {
Button(
text = stringResource(CommonStrings.action_join),
size = ButtonSize.Small,
onClick = { onJoinCallClick(state.callIntent == CallIntent.AUDIO) }
)
}
if (state.callStartTsMillis != null) {
CallDurationText(
state.callStartTsMillis,
)
}
}
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun DirectMessageCallBody(state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
AvatarRow(
avatarDataList = state.joinedMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = stringResource(CommonStrings.common_group_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
// Only show started a call if the last remaining user in the call
// is really the one who started the call
if (caller.userId == event.senderId) {
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else {
val displayMembers = state.joinedMembers.take(3)
AvatarRow(
avatarDataList = displayMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
text = pluralStringResource(
CommonPlurals.screen_timeline_active_call_joined_count,
count = state.joinedMembers.size,
state.joinedMembers.size
),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@Composable
private fun CallDurationText(
startTsMillis: Long,
modifier: Modifier = Modifier,
) {
fun formatElapsed(startTsMillis: Long): String =
(System.currentTimeMillis() - startTsMillis).coerceAtLeast(0).toHumanReadableDuration()
val duration by produceState(
initialValue = formatElapsed(startTsMillis),
startTsMillis,
) {
while (true) {
delay(1.seconds)
value = formatElapsed(startTsMillis)
}
}
Text(
modifier = modifier,
text = "($duration)",
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@PreviewWithLargeHeight
@Composable
internal fun ActiveCallTimelineItemViewPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar
) {
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Active(
listOf(aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl")),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl"),
aMatrixUser(displayName = USER_NAME_BOB)
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE),
aMatrixUser(displayName = USER_NAME_BOB, avatarUrl = "anUrlB"),
aMatrixUser(displayName = USER_NAME_CAROL),
aMatrixUser(displayName = USER_NAME_DAVID),
aMatrixUser(displayName = USER_NAME_CHARLIE),
aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrlC"),
),
isJoined = false,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member still in call, but it is not the creator
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "aliceURl"),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member that is the call creator
RtcNotificationState.Active(
listOf(
aMatrixUser(
id = "@senderId:domain",
displayName = "Sender"
),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
).forEach { (state, isDm) ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
ActiveCallTimelineItemView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
onJoinCallClick = {},
)
}
}
}
}
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
enum class ContentPadding {
Textual,
Media,
CaptionedMedia
CaptionedMedia,
InvalidContent,
}
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
@@ -62,6 +66,8 @@ fun MessageEventBubble(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
customBackgroundColor: Color? = null,
borderColor: Color? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
val clickableModifier = if (isTalkbackActive()) {
@@ -79,10 +85,12 @@ fun MessageEventBubble(
val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints(
modifier = modifier
.drawWithCache {
@@ -103,6 +111,9 @@ fun MessageEventBubble(
// Then draw the content on top of it
drawContent()
// Draw border color, if any
updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) }
// And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) {
drawCircle(
@@ -166,6 +166,7 @@ internal fun MessageShieldViewPreview() {
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
)
)
@@ -52,6 +52,7 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
state: RtcNotificationState.Tombstoned,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
@@ -83,7 +84,7 @@ internal fun TimelineItemCallNotifyView(
Text(
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
text = stringResource(getTextRes(timelineRoomInfo, state)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
@@ -114,13 +115,13 @@ internal fun TimelineItemCallNotifyView(
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
state: RtcNotificationState.Tombstoned
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
when (state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
if (state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
else -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
@@ -166,6 +167,7 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
@@ -0,0 +1,346 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
private val AN_EVENT_ID = EventId($$"$eventId")
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemFileViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAudioViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
ElementTimelineItemPreview {
CompositionLocalProvider(
LocalEventContentValidationState provides cache,
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentNotFoundPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(
AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("Not found")))
))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM"))))
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(mediaSource = MediaSource("error", "{}")),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
)
)
)
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemAttachmentsContent(
attachments = listOf(
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
anAttachmentItem(),
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("error", "{}")),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
)
)
)
)
}
}
private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = ImageMessageType(
filename = "A file",
caption = "A caption",
formattedCaption = null,
source = MediaSource("", ""),
info = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = EventId($$"$text_eventId"),
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "A body",
formatted = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
@@ -33,9 +33,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -88,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData
@@ -122,9 +124,14 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
@@ -176,12 +183,12 @@ fun TimelineItemEventRow(
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
timelineProtectionState = timelineProtectionState,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -201,11 +208,7 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
val sender = MatrixUser(
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
val sender = event.senderProfile.toMatrixUser(event.senderId)
onUserDataClick(sender)
}
@@ -468,6 +471,23 @@ private fun TimelineItemEventRowContent(
)
}
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
.collectOverallState()
val needsInvalidContentCustomisations =
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
currentContentValidationState.hasError() &&
event.content.isMedia
// If the event has a dangerous media content we need to set custom message bubble background and border colors
val themeColors = ElementTheme.colors
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
background to border
}
// Message bubble
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
@@ -494,6 +514,8 @@ private fun TimelineItemEventRowContent(
interactionSource = interactionSource,
onClick = onContentClick,
onLongClick = onLongClick,
customBackgroundColor = dangerousContentBubbleColor,
borderColor = borderColor,
) {
MessageEventBubbleContent(
event = event,
@@ -747,6 +769,7 @@ private fun MessageEventBubbleContent(
}
ContentPadding.CaptionedMedia ->
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
}
val threadDecoration = @Composable {
@@ -770,26 +793,40 @@ private fun MessageEventBubbleContent(
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
.collectOverallState()
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val shape = RoundedCornerShape(6.dp)
val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) {
val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f)
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
}
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
modifier = talkbackCompatModifier
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
.border(1.dp, borderColor, shape)
.background(backgroundColor, shape)
.padding(4.dp)
) {
val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(inReplyTo) {
val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect
updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState))
}
InReplyToView(
inReplyTo = inReplyTo,
contentValidationValue = currentContentValidationState,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
)
}
@@ -809,30 +846,47 @@ private fun MessageEventBubbleContent(
}
}
val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
val needsInvalidContentLayout =
// Gallery events should not apply custom paddings or layout dispositions
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
contentValidationState.hasError()
val timestampPosition = if (needsInvalidContentLayout) {
// The invalid content view will be displayed in all these cases, independent of the event content
TimestampPosition.Aligned
} else {
when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
val paddingBehaviour = if (needsInvalidContentLayout) {
ContentPadding.InvalidContent
} else {
when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
}
CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
@@ -905,6 +959,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = false,
displayedStatus = null,
),
timestamp = 0L,
)
@@ -1083,6 +1138,7 @@ internal fun ThreadSummaryViewPreview() {
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = true,
displayedStatus = null,
),
timestamp = 0L,
)
@@ -19,14 +19,15 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
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.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -60,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -132,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -147,12 +148,18 @@ private fun TimelineItemGroupedEventsRowContent(
},
) {
Column(modifier = modifier.animateContentSize()) {
val count = timelineItem.events.size
// A group made entirely of redacted events is a collapsed run of deleted messages
// (element-web style); anything else is the regular run of room state changes. For the
// redacted case we show only the count: the SDK does not expose who performed the redaction,
// and showing the original authors would be misleading.
val headerText = if (timelineItem.isRedactedMessagesGroup()) {
pluralStringResource(R.plurals.screen_room_timeline_redacted_messages, count, count)
} else {
pluralStringResource(R.plurals.screen_room_timeline_state_changes, count, count)
}
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.screen_room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
text = headerText,
isExpanded = isExpanded,
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
onClick = onExpandGroupClick,
@@ -179,6 +186,7 @@ private fun TimelineItemGroupedEventsRowContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = {},
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
@@ -252,3 +260,31 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
eventSink = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemRedactedMessagesGroupPreview() = ElementPreview {
// A collapsed run of deleted messages, shown as a single "N removed messages" header.
TimelineItemGroupedEventsRowContent(
isExpanded = false,
onExpandGroupClick = {},
timelineItem = aRedactedMessagesGroupedEvents(count = 11),
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}
@@ -31,12 +31,12 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.designsystem.colors.gradientSubtleColors
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
@@ -71,15 +71,16 @@ internal fun TimelineItemRow(
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) },
@@ -119,18 +120,32 @@ internal fun TimelineItemRow(
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
timelineProtectionState = timelineProtectionState,
eventSink = eventSink,
)
}
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
when (timelineItem.content.state) {
is RtcNotificationState.Active -> ActiveCallTimelineItemView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
)
is RtcNotificationState.Started, is RtcNotificationState.Declined ->
TimelineItemCallNotifyView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
}
}
else -> {
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -43,6 +45,7 @@ fun TimelineItemStateEventRow(
onClick: () -> Unit,
onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
timelineProtectionState: TimelineProtectionState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier
) {
@@ -67,11 +70,11 @@ fun TimelineItemStateEventRow(
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
onLinkClick = {},
onLinkLongClick = {},
hideMediaContent = false,
onShowContentClick = {},
timelineProtectionState = timelineProtectionState,
eventSink = eventSink,
onContentClick = null,
onGalleryItemClick = {},
@@ -107,6 +110,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
onClick = {},
onLongClick = {},
onReadReceiptsClick = {},
timelineProtectionState = aTimelineProtectionState(),
eventSink = {}
)
}
@@ -8,20 +8,23 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@OptIn(ExperimentalMaterial3Api::class)
@@ -29,46 +32,55 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
fun CustomReactionBottomSheet(
state: CustomReactionState,
onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit,
emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
val coroutineScope = rememberCoroutineScope()
val target = state.target as? CustomReactionState.Target.Success
fun onDismiss() {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
}
when (state.target) {
CustomReactionState.Target.None,
is CustomReactionState.Target.Loading -> Unit
is CustomReactionState.Target.Success -> {
fun onEmojiSelectedDismiss(emoji: Emoji) {
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
onSelectEmoji(state.target.event.eventOrTransactionId, emoji)
}
}
fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
onSelectEmoji(target.event.eventOrTransactionId, emoji)
}
}
fun onDismiss() {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
}
if (target?.emojibaseStore != null && target.event.eventId != null) {
ModalBottomSheet(
onDismissRequest = ::onDismiss,
sheetState = sheetState,
modifier = modifier,
scrollable = false,
) {
val presenter = remember {
EmojiPickerPresenter(
emojibaseStore = target.emojibaseStore,
recentEmojis = state.recentEmojis,
coroutineDispatchers = CoroutineDispatchers.Default,
ModalBottomSheet(
onDismissRequest = ::onDismiss,
sheetState = sheetState,
modifier = modifier,
scrollable = false,
) {
ExpandSheetWhenImeIsVisible(sheetState)
emojiPickerRenderer.Render(
state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss,
selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(),
contentDescription = { emoji, isSelected ->
a11yReactionAction(emoji = emoji.unicode, userAlreadyReacted = isSelected)
},
)
}
EmojiPicker(
onSelectEmoji = ::onEmojiSelectedDismiss,
state = presenter.present(),
selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible) {
if (isImeVisible) {
sheetState.expand()
}
}
}
@@ -9,59 +9,53 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.launch
@Inject
class CustomReactionPresenter(
private val emojibaseProvider: EmojibaseProvider,
private val getRecentEmojis: GetRecentEmojis,
emojiPickerPresenterFactory: EmojiPickerPresenter.Factory,
getRecentEmojis: GetRecentEmojis,
) : Presenter<CustomReactionState> {
private val emojiPickerPresenter = emojiPickerPresenterFactory.create(getRecentEmojis)
@Composable
override fun present(): CustomReactionState {
val localCoroutineScope = rememberCoroutineScope()
var recentEmojis by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
val target: MutableState<CustomReactionState.Target> = remember {
mutableStateOf(CustomReactionState.Target.None)
}
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
target.value = CustomReactionState.Target.Loading(event)
localCoroutineScope.launch {
recentEmojis = getRecentEmojis().getOrNull() ?: persistentListOf()
target.value = CustomReactionState.Target.Success(
event = event,
emojibaseStore = emojibaseProvider.emojibaseStore
)
}
}
fun handleDismissCustomReactionSheet() {
target.value = CustomReactionState.Target.None
}
var internalTarget by remember { mutableStateOf<InternalTarget>(InternalTarget.None) }
val emojiPickerState = emojiPickerPresenter.present()
fun handleEvent(event: CustomReactionEvent) {
when (event) {
is CustomReactionEvent.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event)
is CustomReactionEvent.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
is CustomReactionEvent.ShowCustomReactionSheet -> {
internalTarget = InternalTarget.Showing(event.event)
}
is CustomReactionEvent.DismissCustomReactionSheet -> {
internalTarget = InternalTarget.None
}
}
}
val event = (target.value as? CustomReactionState.Target.Success)?.event
val selectedEmoji = event
val computedTarget = when (val target = internalTarget) {
InternalTarget.None -> CustomReactionState.Target.None
is InternalTarget.Showing -> if (emojiPickerState.isReady) {
CustomReactionState.Target.Success(
event = target.event,
emojiPickerState = emojiPickerState,
)
} else {
CustomReactionState.Target.Loading(target.event)
}
}
val eventForSelection = (computedTarget as? CustomReactionState.Target.Success)?.event
val selectedEmoji = eventForSelection
?.reactionsState
?.reactions
?.mapNotNull { if (it.isHighlighted) it.key else null }
@@ -69,10 +63,14 @@ class CustomReactionPresenter(
.toImmutableSet()
return CustomReactionState(
target = target.value,
target = computedTarget,
selectedEmoji = selectedEmoji,
recentEmojis = recentEmojis,
eventSink = ::handleEvent,
)
}
}
private sealed interface InternalTarget {
data object None : InternalTarget
data class Showing(val event: TimelineItem.Event) : InternalTarget
}
@@ -8,15 +8,13 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState(
val target: Target,
val selectedEmoji: ImmutableSet<String>,
val recentEmojis: ImmutableList<String>,
val eventSink: (CustomReactionEvent) -> Unit,
) {
sealed interface Target {
@@ -24,7 +22,7 @@ data class CustomReactionState(
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val emojibaseStore: EmojibaseStore,
val emojiPickerState: EmojiPickerState,
) : Target
}
}
@@ -1,100 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
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.Text
import kotlinx.collections.immutable.persistentListOf
@Composable
fun EmojiItem(
item: Emoji,
isSelected: Boolean,
onSelectEmoji: (Emoji) -> Unit,
modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp,
) {
val backgroundColor = if (isSelected) {
ElementTheme.colors.bgActionPrimaryRest
} else {
Color.Transparent
}
val description = a11yReactionAction(
emoji = item.unicode,
userAlreadyReacted = isSelected,
)
Box(
modifier = modifier
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.background(backgroundColor, CircleShape)
.clickable(
enabled = true,
onClick = { onSelectEmoji(item) },
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
interactionSource = remember { MutableInteractionSource() }
)
.clearAndSetSemantics {
contentDescription = description
},
contentAlignment = Alignment.Center
) {
Text(
text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize),
)
}
}
@PreviewsDayNight
@Composable
internal fun EmojiItemPreview() = ElementPreview {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (isSelected in listOf(true, false)) {
EmojiItem(
item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = null
),
isSelected = isSelected,
onSelectEmoji = {},
)
}
}
}
@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.EmojiFlags
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
import androidx.compose.material.icons.outlined.EmojiNature
import androidx.compose.material.icons.outlined.EmojiObjects
import androidx.compose.material.icons.outlined.EmojiPeople
import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.features.messages.impl.R
@get:StringRes
val EmojibaseCategory.title: Int
get() = when (this) {
EmojibaseCategory.People -> R.string.emoji_picker_category_people
EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
EmojibaseCategory.Places -> R.string.emoji_picker_category_places
EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
}
val EmojibaseCategory.icon: ImageVector
get() = when (this) {
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}
@@ -1,149 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
onSelectEmoji: (Emoji) -> Unit,
state: EmojiPickerState,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size })
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(EmojiPickerEvent.ToggleSearchActive(it)) },
windowInsets = WindowInsets(0, 0, 0, 0),
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
) { emojis ->
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
)
}
if (!state.isSearchActive) {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
state.categories.forEachIndexed { index, category ->
Tab(
icon = {
when (category.icon) {
is IconSource.Resource -> Icon(
resourceId = category.icon.id,
contentDescription = stringResource(id = category.titleId)
)
is IconSource.Vector -> Icon(
imageVector = category.icon.vector,
contentDescription = stringResource(id = category.titleId)
)
}
},
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val emojis = state.categories[index].emojis
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
)
}
}
}
}
@Composable
private fun EmojiResults(
emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 48.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(emojis, key = { it.unicode }) { item ->
EmojiItem(
modifier = Modifier.aspectRatio(1f),
item = item,
isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji,
emojiSize = 32.dp.toSp(),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview {
EmojiPicker(
onSelectEmoji = {},
state = state,
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -1,13 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
sealed interface EmojiPickerEvent {
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent
}
@@ -1,114 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.features.messages.impl.timeline.components.customreaction.title
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
class EmojiPickerPresenter(
private val emojibaseStore: EmojibaseStore,
private val recentEmojis: ImmutableList<String>,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<EmojiPickerState> {
@Composable
override fun present(): EmojiPickerState {
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
val recentEmojiIcon = CompoundIcons.History()
val categories = remember {
val providedCategories = emojibaseStore.categories.map { (category, emojis) ->
EmojiCategory(
titleId = category.title,
icon = IconSource.Vector(category.icon),
emojis = emojis
)
}
if (recentEmojis.isNotEmpty()) {
val recentEmojis = recentEmojis.mapNotNull { recentEmoji ->
emojibaseStore.allEmojis.find { it.unicode == recentEmoji }
}.toImmutableList()
val recentCategory =
EmojiCategory(
titleId = R.string.emoji_picker_category_recent,
icon = IconSource.Vector(recentEmojiIcon),
emojis = recentEmojis
)
(listOf(recentCategory) + providedCategories).toImmutableList()
} else {
providedCategories.toImmutableList()
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
emojiResults = if (searchQuery.isEmpty()) {
SearchBarResultState.Initial()
} else {
// Add a small delay to avoid doing too many computations when the user is typing quickly
delay(100.milliseconds)
val lowercaseQuery = searchQuery.lowercase()
val results = withContext(coroutineDispatchers.computation) {
emojibaseStore.allEmojis
.asSequence()
.filter { emoji ->
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
emoji.shortcodes.any { it.contains(lowercaseQuery) }
}
.take(60)
.toImmutableList()
}
SearchBarResultState.Results(results)
}
}
val isInPreview = LocalInspectionMode.current
fun handleEvent(event: EmojiPickerEvent) {
when (event) {
// For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically
is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) {
isSearchActive = event.isActive
}
}
}
return EmojiPickerState(
categories = categories,
allEmojis = emojibaseStore.allEmojis,
searchQuery = queryState,
isSearchActive = isSearchActive,
searchResults = emojiResults,
eventSink = ::handleEvent,
)
}
}
@@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
// Emoji is unstable (because from an external library?), so we annotate with @Immutable
@Immutable
data class EmojiPickerState(
val categories: ImmutableList<EmojiCategory>,
val allEmojis: ImmutableList<Emoji>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvent) -> Unit,
)
/**
* Represents a category of emojis with a title id, icon, and the list of associated emojis.
*/
data class EmojiCategory(
@StringRes val titleId: Int,
val icon: IconSource,
val emojis: ImmutableList<Emoji>,
)
@@ -1,84 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.features.messages.impl.timeline.components.customreaction.title
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
override val values: Sequence<EmojiPickerState>
get() = sequenceOf(
anEmojiPickerState(),
anEmojiPickerState(isSearchActive = true),
anEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
anEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(emojiList())
),
)
}
private fun recentEmojisCategory() = EmojiCategory(
titleId = R.string.emoji_picker_category_recent,
icon = IconSource.Resource(CompoundDrawables.ic_compound_history),
emojis = emojiList(),
)
private fun emojiList(): ImmutableList<Emoji> = persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
)
)
internal fun anEmojiPickerState(
categories: ImmutableList<EmojiCategory> = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map {
EmojiCategory(
titleId = it.title,
icon = IconSource.Vector(it.icon),
emojis = emojiList(),
)
}).toImmutableList(),
allEmojis: ImmutableList<Emoji> = categories.flatMap { it.emojis }.toImmutableList(),
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
eventSink: (EmojiPickerEvent) -> Unit = {},
) = EmojiPickerState(
categories = categories,
allEmojis = allEmojis,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
eventSink = eventSink,
)
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -25,8 +24,10 @@ 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.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
@@ -38,9 +39,9 @@ fun TimelineItemAttachmentView(
iconContentDescription: String?,
filename: String,
fileExtensionAndSize: String,
caption: String?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) {
Column(
modifier = modifier,
@@ -50,16 +51,9 @@ fun TimelineItemAttachmentView(
iconContentDescription = iconContentDescription,
filename = filename,
fileExtensionAndSize = fileExtensionAndSize,
hasCaption = caption != null,
isLoading = !contentValidationValue.isValidated(),
onContentLayoutChange = onContentLayoutChange,
)
if (caption != null) {
TimelineItemAttachmentCaptionView(
modifier = Modifier.padding(top = 4.dp),
caption = caption,
onContentLayoutChange = onContentLayoutChange,
)
}
}
}
@@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView(
iconContentDescription: String?,
filename: String,
fileExtensionAndSize: String,
hasCaption: Boolean,
isLoading: Boolean,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -86,12 +80,19 @@ private fun TimelineItemAttachmentHeaderView(
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = ElementTheme.colors.iconPrimary,
)
} else {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
}
}
Column {
Text(
@@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView(
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = if (hasCaption) {
{}
} else {
ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
},
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
),
)
}
}
}
@Composable
private fun TimelineItemAttachmentCaptionView(
caption: String,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = caption,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
)
)
}
@@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<Timeline
),
aTimelineItemAttachmentsContent(
body = "Files",
caption = "Files mixed with media",
attachments = listOf(
anAttachmentItem(
filename = "report.pdf",
@@ -7,31 +7,30 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -39,8 +38,6 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
@@ -50,66 +47,124 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
import kotlin.math.max
@Composable
fun TimelineItemAttachmentsListView(
eventId: EventId?,
content: TimelineItemAttachmentsContent,
onGalleryItemClick: (Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
content.attachments.forEachIndexed { index, attachment ->
Column {
if (index > 0) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
val contentValidationState = rememberEventContentValidationState(
eventId = eventId,
needsValidation = content.isMedia,
)
Column(modifier = modifier.fillMaxWidth()) {
val validationStates = remember(content.attachments) {
buildList {
for (attachment in content.attachments) {
add(validationStateForAttachment(attachment, contentValidationState))
}
}.toMutableStateList()
}
content.attachments.forEachIndexed { index, attachment ->
Column(modifier = Modifier.fillMaxWidth()) {
val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl)
val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
validationStateForAttachment(attachment, contentValidationState)
}
LaunchedEffect(itemContentValidationState) {
validationStates[index] = itemContentValidationState
}
val needsSeparator = index in 1..max(1, content.attachments.lastIndex)
val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true
if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
when (itemContentValidationState) {
ContentValidationValue.Invalid -> {
val shape = RoundedCornerShape(6.dp)
InvalidContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
)
}
AttachmentListItem(
attachment = attachment,
onClick = { onGalleryItemClick(index) },
is ContentValidationValue.UnrecoverableError -> {
val shape = RoundedCornerShape(6.dp)
NotFoundContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
)
}
ContentValidationValue.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally))
}
ContentValidationValue.Valid -> {
AttachmentListItem(
attachment = attachment,
onClick = { onGalleryItemClick(index) },
)
}
else -> {
// TODO handle cases where either the content hasn't started validation or it failed to do so
}
}
if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
}
}
if (content.showCaption) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp)
.widthIn(min = 120.dp),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
private fun validationStateForAttachment(
attachment: AttachmentItem,
contentValidationState: ContentValidationState,
): ContentValidationValue {
val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown
val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl)
return validationStateForAttachment(thumbnailValue, mediaValue)
}
private fun validationStateForAttachment(
thumbnailValue: ContentValidationValue,
mediaValue: ContentValidationValue,
): ContentValidationValue {
return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) {
ContentValidationValue.Invalid
} else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) {
listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() }
} else if (thumbnailValue.isLoading() || mediaValue.isLoading()) {
ContentValidationValue.Loading
} else {
mediaValue
}
}
@@ -213,10 +268,8 @@ internal fun TimelineItemAttachmentsListViewPreview(
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
) = ElementPreview {
TimelineItemAttachmentsListView(
eventId = EventId("\$eventId"),
content = content,
onGalleryItemClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@@ -16,11 +16,13 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
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.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
TimelineItemAttachmentView(
@@ -28,9 +30,9 @@ fun TimelineItemAudioView(
iconContentDescription = null,
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
contentValidationValue = contentValidationValue,
)
}
@@ -41,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
TimelineItemAudioView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
@@ -8,9 +8,23 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
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.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
@@ -32,132 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
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.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemEventContentView(
eventId: EventId?,
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit),
timelineProtectionState: TimelineProtectionState,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
modifier = modifier,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLocationContent -> {
TimelineItemLocationView(
content = content.ensureActiveLiveLocation(),
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
modifier = modifier
)
val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) {
timelineProtectionState.hideMediaContent(eventId)
}
val onShowContentClick = remember(timelineProtectionState.eventSink) {
{
timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId))
}
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemGalleryContent -> TimelineItemGalleryView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = {},
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemFileContent -> TimelineItemFileView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
eventSink = eventSink,
modifier = modifier,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
}
val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia)
val overallValidationState by contentValidationState.collectOverallState()
val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent }
if (eventId != null) {
ValidateMediaHelper(eventId, content, contentValidationState, eventSink)
}
val caption = content.captionOrNull()
val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent
// If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content
val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) {
if (showCaption) {
{}
} else {
onContentLayoutChange
}
}
val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem
val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem
EqualWidthColumn(modifier = modifier) {
if (displayInvalidContent) {
InvalidContentView(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = calculatedOnContentLayoutChange,
// Icon + horizontal paddings
extraWidth = 24.dp + 20.dp,
)
)
} else if (displayContentNotFound) {
NotFoundContentView(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = calculatedOnContentLayoutChange,
// Icon + horizontal paddings
extraWidth = 24.dp + 20.dp,
)
)
} else {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemLocationContent -> {
TimelineItemLocationView(
content = content.ensureActiveLiveLocation(),
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
)
}
is TimelineItemImageContent -> {
TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemStickerContent -> {
TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemVideoContent -> {
TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemGalleryContent -> TimelineItemGalleryView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
)
is TimelineItemFileContent -> {
TimelineItemFileView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemAudioContent -> {
TimelineItemAudioView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView()
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
eventSink = eventSink,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
}
}
if (showCaption) {
val padding = if (displayInvalidContent || displayContentNotFound) {
PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 0.dp)
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> PaddingValues(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 0.dp)
else -> PaddingValues(horizontal = 12.dp, vertical = 8.dp)
}
}
CaptionView(
modifier = Modifier.padding(padding),
caption = caption,
formattedCaption = content.formattedCaptionOrNull(),
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
}
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
}
}
@Composable
private fun CaptionView(
caption: String,
formattedCaption: CharSequence?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val caption = if (LocalInspectionMode.current) {
SpannedString(caption)
} else {
formattedCaption ?: SpannedString(caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = modifier,
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
@Composable
private fun ValidateMediaHelper(
eventId: EventId,
content: TimelineItemEventContent,
contentValidationState: ContentValidationState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
) {
val mediaSources = when (content) {
is TimelineItemImageContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemStickerContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemVideoContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemFileContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemAudioContent -> listOf(content.mediaSource)
is TimelineItemVoiceContent -> listOf(content.mediaSource)
is TimelineItemGalleryContent -> content.items.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
is TimelineItemAttachmentsContent -> content.attachments.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
else -> return
}
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(eventId, mediaSources) {
updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSources, contentValidationState))
}
}
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
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.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings
/**
@@ -26,6 +27,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun TimelineItemFileView(
content: TimelineItemFileContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
TimelineItemAttachmentView(
@@ -33,9 +35,9 @@ fun TimelineItemFileView(
iconContentDescription = stringResource(CommonStrings.common_file),
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
contentValidationValue = contentValidationValue,
)
}
@@ -46,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
TimelineItemFileView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
}
@@ -116,6 +116,7 @@ fun aGalleryItem(
width: Int = 400,
height: Int = 300,
duration: Duration = Duration.ZERO,
mediaSource: MediaSource = MediaSource(url = "", json = ""),
): GalleryItem {
return GalleryItem(
filename = filename,
@@ -125,7 +126,7 @@ fun aGalleryItem(
GalleryItem.Type.File -> "application/pdf"
GalleryItem.Type.Image -> "image/jpeg"
},
mediaSource = MediaSource(url = "", json = ""),
mediaSource = mediaSource,
thumbnailSource = null,
width = width,
height = height,
@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -21,39 +20,35 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList
private const val MAX_TILES = 5
@@ -67,12 +62,10 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp
@Composable
fun TimelineItemGalleryView(
eventId: EventId?,
content: TimelineItemGalleryContent,
onGalleryItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val totalItems = content.items.size
@@ -87,21 +80,25 @@ fun TimelineItemGalleryView(
when (totalItems) {
0 -> Unit
1 -> SingleItemLayout(
eventId = eventId,
item = content.items[0],
onClick = { onGalleryItemClick(0) },
onLongClick = onLongClick,
)
2 -> TwoItemLayout(
eventId = eventId,
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
3 -> ThreeItemLayout(
eventId = eventId,
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
else -> FourPlusItemLayout(
eventId = eventId,
items = content.items,
showOverflow = showOverflow,
overflowCount = overflowCount,
@@ -110,40 +107,18 @@ fun TimelineItemGalleryView(
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp)
.widthIn(min = 120.dp),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
@Composable
private fun SingleItemLayout(
eventId: EventId?,
item: GalleryItem,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
) {
GalleryItemCell(
eventId = eventId,
item = item,
isLast = false,
remainingCount = 0,
@@ -157,6 +132,7 @@ private fun SingleItemLayout(
@Composable
private fun TwoItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
@@ -167,6 +143,7 @@ private fun TwoItemLayout(
) {
items.forEachIndexed { index, item ->
GalleryItemCell(
eventId = eventId,
item = item,
isLast = false,
remainingCount = 0,
@@ -182,6 +159,7 @@ private fun TwoItemLayout(
@Composable
private fun ThreeItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
@@ -191,6 +169,7 @@ private fun ThreeItemLayout(
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
GalleryItemCell(
eventId = eventId,
item = items[0],
isLast = false,
remainingCount = 0,
@@ -206,6 +185,7 @@ private fun ThreeItemLayout(
) {
for (it in 1..2) {
GalleryItemCell(
eventId = eventId,
item = items[it],
isLast = false,
remainingCount = 0,
@@ -222,6 +202,7 @@ private fun ThreeItemLayout(
@Composable
private fun FourPlusItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>,
showOverflow: Boolean,
overflowCount: Int,
@@ -238,6 +219,7 @@ private fun FourPlusItemLayout(
) {
for (it in 0..1) {
GalleryItemCell(
eventId = eventId,
item = items[it],
isLast = false,
remainingCount = 0,
@@ -260,6 +242,7 @@ private fun FourPlusItemLayout(
if (itemIndex < items.size) {
val isOverflowItem = showOverflow && i == bottomRowItems - 1
GalleryItemCell(
eventId = eventId,
item = items[itemIndex],
isLast = isOverflowItem,
remainingCount = if (isOverflowItem) overflowCount else 0,
@@ -277,6 +260,7 @@ private fun FourPlusItemLayout(
@Composable
private fun GalleryItemCell(
eventId: EventId?,
item: GalleryItem,
isLast: Boolean,
remainingCount: Int,
@@ -284,12 +268,34 @@ private fun GalleryItemCell(
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true)
val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl)
val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) {
ContentValidationValue.Invalid
} else if (thumbnailContentValidationState.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) {
listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError }
} else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) {
ContentValidationValue.Loading
} else {
mediaContentValidationState
}
}
Box(
modifier = modifier
.blurHashBackground(item.blurhash, alpha = 0.9f)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
.then(
if (itemContentValidationState.isValid()) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
@@ -305,7 +311,20 @@ private fun GalleryItemCell(
VideoOverlay(duration = item.duration)
}
if (isLast && remainingCount > 0) {
if (itemContentValidationState.isLoading()) {
CircularProgressIndicator()
} else if (itemContentValidationState.hasError()) {
Box(
modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Error(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
} else if (isLast && remainingCount > 0) {
RemainingCountOverlay(count = remainingCount)
}
}
@@ -374,11 +393,9 @@ internal fun TimelineItemGalleryViewPreview(
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
) = ElementPreview {
TimelineItemGalleryView(
eventId = null,
content = content,
onGalleryItemClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@@ -8,18 +8,12 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -31,118 +25,99 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
private const val TALL_IMAGE_RATIO_DIVISOR = 3
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val a11yLabel = stringResource(CommonStrings.common_image)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) {
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) {
Modifier.clip(RoundedCornerShape(10.dp))
} else {
Modifier
}
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally),
modifier = containerModifier
.blurHashBackground(content.blurhash, alpha = 0.9f)
.align(Alignment.CenterHorizontally),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
if (isContentBeingValidated) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val width = content.width ?: 0
val height = content.height ?: 0
// if image is narrow and tall use DEFAULT_ASPECT_RATIO
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) {
DEFAULT_ASPECT_RATIO
} else {
content.aspectRatio ?: DEFAULT_ASPECT_RATIO
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { state ->
val url = content.thumbnailMediaRequestData.source?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
}
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
@@ -157,89 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Image with null aspectRatio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
}
@PreviewsDayNight
@Composable
internal fun ATimelineItemEventRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 40,
height = 20,
aspectRatio = 40f / 20f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
caption = "Narrow image with null aspectRatio",
width = 80,
height = 300,
aspectRatio = 80f / 300f,
height = 150,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Narrow image with null aspectRatio",
width = 80,
height = 300,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
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.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@@ -47,11 +52,15 @@ fun TimelineItemStickerView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowClick: () -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
Column(
modifier = modifier.semantics { contentDescription = description },
modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally),
) {
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
@@ -63,11 +72,12 @@ fun TimelineItemStickerView(
hideContent = hideMediaContent,
onShowClick = onShowClick,
) {
var isLoaded by remember { mutableStateOf(false) }
var isThumbnailLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (onContentClick != null) {
Modifier
@@ -91,9 +101,22 @@ fun TimelineItemStickerView(
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
onState = { state ->
val url = content.preferredMediaSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isThumbnailLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
}
if (isContentBeingValidated) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
@@ -107,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
onContentClick = {},
onLongClick = {},
onShowClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.wysiwyg.compose.EditorStyledText
@@ -43,6 +44,9 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
if (LocalUiTestMode.current) return
val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when {
@@ -8,22 +8,17 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.modifiers.roundedBackground
@@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemVideoView(
@@ -75,15 +63,13 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) {
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) {
Modifier
.padding(top = 6.dp)
@@ -91,6 +77,9 @@ fun TimelineItemVideoView(
} else {
Modifier
}
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
@@ -127,48 +116,35 @@ fun TimelineItemVideoView(
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
onState = { state ->
val url = content.thumbnailSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { hideFromAccessibility() }
)
if (!isContentBeingValidated) {
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { hideFromAccessibility() }
)
}
} else {
CircularProgressIndicator()
}
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
@@ -181,9 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -196,39 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "Video with null aspect ratio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -51,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
@@ -63,6 +64,7 @@ fun TimelineItemVoiceView(
state: VoiceMessageState,
content: TimelineItemVoiceContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
fun playPause() {
@@ -103,12 +105,16 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically,
) {
if (!isTalkbackActive()) {
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
if (contentValidationValue.isValid()) {
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
} else {
ProgressButton(displayImmediately = true)
}
}
Spacer(Modifier.width(8.dp))
@@ -283,6 +289,7 @@ internal fun TimelineItemVoiceViewPreview(
state = timelineItemVoiceViewParameters.state,
content = timelineItemVoiceViewParameters.content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
@@ -296,6 +303,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
state = it.state,
content = it.content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
}
@@ -26,9 +26,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -37,6 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.ui.strings.CommonStrings
private val CORNER_RADIUS = 8.dp
@@ -52,6 +55,8 @@ fun GroupHeaderView(
val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
val expandedStateDescription = stringResource(CommonStrings.a11y_state_expanded)
val collapsedStateDescription = stringResource(CommonStrings.a11y_state_collapsed)
Box(
modifier = modifier
.fillMaxWidth()
@@ -62,6 +67,7 @@ fun GroupHeaderView(
)
.clearAndSetSemantics {
contentDescription = text
stateDescription = if (isExpanded) expandedStateDescription else collapsedStateDescription
},
contentAlignment = Alignment.Center
) {
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.layout
import android.text.Layout
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max
import kotlin.math.min
@@ -41,6 +43,7 @@ import kotlin.math.roundToInt
* @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.
*/
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
@Composable
fun ContentAvoidingLayout(
overlay: @Composable () -> Unit,
@@ -52,6 +55,15 @@ fun ContentAvoidingLayout(
) {
val scope = remember { ContentAvoidingLayoutScopeInstance() }
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
if (LocalUiTestMode.current) {
Column {
scope.content()
overlay()
}
return
}
Layout(
modifier = modifier,
content = {
@@ -86,7 +86,7 @@ private fun ReadReceiptBottomSheetContent(
LazyColumn {
item {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
@@ -21,6 +21,8 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@@ -39,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@@ -58,13 +61,14 @@ class TimelineItemContentFactory(
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
suspend fun create(eventTimelineItem: EventTimelineItem, roomMembers: List<RoomMember>): TimelineItemEventContent {
return create(
itemContent = eventTimelineItem.content,
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
sender = eventTimelineItem.sender,
senderProfile = eventTimelineItem.senderProfile,
roomMembers = roomMembers,
)
}
@@ -74,6 +78,7 @@ class TimelineItemContentFactory(
isEditable: Boolean,
sender: UserId,
senderProfile: ProfileDetails,
roomMembers: List<RoomMember> = emptyList(),
): TimelineItemEventContent {
val isOutgoing = sessionId == sender
return when (itemContent) {
@@ -106,10 +111,26 @@ class TimelineItemContentFactory(
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent(
callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
state = when {
itemContent.activeMembers.isNotEmpty() -> {
RtcNotificationState.Active(
joinedMembers = itemContent.activeMembers.map { active ->
roomMembers.find {
it.userId == active
}?.toMatrixUser() ?: MatrixUser(active)
},
isJoined = itemContent.isJoined,
callStartTsMillis = itemContent.callStartTsMillis,
callIntent = itemContent.callIntent
)
}
else -> {
if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
}
}
)
is UnknownContent -> TimelineItemUnknownContent
@@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.map
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val GROUPING_TIMEOUT_MS = 5 * 60 * 1000L
@AssistedInject
class TimelineItemEventFactory(
@Assisted private val config: TimelineItemsFactoryConfig,
@@ -108,7 +110,7 @@ class TimelineItemEventFactory(
senderId = currentSender,
senderProfile = senderProfile,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
content = contentFactory.create(currentTimelineItem.event, roomMembers),
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
@@ -221,8 +223,14 @@ class TimelineItemEventFactory(
val previousSender = prevTimelineItem?.event?.sender
val nextSender = nextTimelineItem?.event?.sender
val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
val previousIsGroupable = prevTimelineItem?.let {
it.canBeDisplayedInBubbleBlock() &&
currentTimelineItem.event.timestamp - it.event.timestamp <= GROUPING_TIMEOUT_MS
}.orTrue()
val nextIsGroupable = nextTimelineItem?.let {
it.canBeDisplayedInBubbleBlock() &&
it.event.timestamp - currentTimelineItem.event.timestamp <= GROUPING_TIMEOUT_MS
}.orTrue()
return when {
previousSender != currentSender && nextSender == currentSender -> {
@@ -44,6 +44,14 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
/**
* Return true when every event in the group is a redacted (deleted) message, i.e. the group is a
* collapsed run of deleted messages rather than the usual run of room state changes. Used to pick
* the group header label. An empty group is not considered a redacted group.
*/
internal fun TimelineItem.GroupedEvents.isRedactedMessagesGroup(): Boolean =
events.isNotEmpty() && events.all { it.content is TimelineItemRedactedContent }
/**
* Return true if the Event can be grouped in a collapse/expand block
* When [canBeGrouped] returns a value, [canBeDisplayedInBubbleBlock] MUST return the opposite value.
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UniqueId
import kotlinx.collections.immutable.toImmutableList
@@ -47,7 +48,8 @@ class TimelineItemGrouper {
if (currentGroup.isNotEmpty()) {
result.addGroup(groupIds, currentGroup)
}
return result
// Finally, fold runs of consecutive deleted messages into a single group (element-web style).
return result.collapseRedactedRuns(groupIds)
}
}
@@ -89,3 +91,56 @@ private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<Timel
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): UniqueId = UniqueId("${timelineItem.identifier()}_group")
// Runs shorter than this are left as individual "Message removed" tiles, like element-web.
internal const val MIN_REDACTED_RUN_SIZE = 3
/**
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single
* [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable
* "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
* (day dividers included), are left untouched.
*
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events
* have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
* than an ordinary group, and a redacted group has to stay entirely redacted so its header can show a
* plain count rather than being mixed in with the surrounding state changes.
*
* Events are kept oldest-first to match the other groups, and the group id is resolved through the
* grouper's [getOrPutGroupId] registry, also like the other groups, so it stays stable however the
* run grows: older history loading in, or a new adjacent message being redacted. A stable id keeps
* the user's expand/collapse state across timeline updates.
*/
internal fun List<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String, String>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>()
val run = mutableListOf<TimelineItem.Event>()
fun flushRun() {
when {
run.isEmpty() -> Unit
run.size < MIN_REDACTED_RUN_SIZE -> result.addAll(run)
else -> {
val events = run.reversed()
result.add(
TimelineItem.GroupedEvents(
id = UniqueId(groupIds.getOrPutGroupId(events)),
events = events.toImmutableList(),
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
)
)
}
}
run.clear()
}
for (item in this) {
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent) {
run.add(item)
} else {
flushRun()
result.add(item)
}
}
flushRun()
return result
}
@@ -19,8 +19,6 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
aTimelineItemAudioContent(caption = "A caption"),
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
@@ -15,6 +15,9 @@ import kotlin.time.Duration
@Immutable
sealed interface TimelineItemEventContent {
val type: String
val isMedia: Boolean
get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent || this is TimelineItemAttachmentsContent
}
interface TimelineItemEventMutableContent {
@@ -55,10 +58,12 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemFileContent,
is TimelineItemAudioContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
// Live location shares can't be forwarded, the SDK rejects them, so we only show the option for static locations
// See https://github.com/element-hq/element-x-android/issues/7190
is TimelineItemLocationContent -> mode is TimelineItemLocationContent.Mode.Static
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
@@ -115,6 +120,13 @@ fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
else -> null
}
fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (this) {
is TimelineItemEventContentWithAttachment -> formattedCaption
is TimelineItemGalleryContent -> formattedCaption
is TimelineItemAttachmentsContent -> formattedCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -123,3 +135,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? {
else -> null
}
}
fun TimelineItemEventContentWithAttachment.blurHash(): String? {
return when (this) {
is TimelineItemImageContent -> blurhash
is TimelineItemVideoContent -> blurHash
is TimelineItemStickerContent -> blurhash
else -> null
}
}
@@ -135,6 +135,7 @@ fun anAttachmentItem(
fileSize: Long? = 1_000_000L,
formattedFileSize: String = "1MB",
mimeType: String? = null,
mediaSource: MediaSource = MediaSource(url = "", json = ""),
thumbnailSource: MediaSource? = null,
hasThumbnail: Boolean = false,
) = AttachmentItem(
@@ -143,7 +144,7 @@ fun anAttachmentItem(
hasThumbnail -> "image/jpeg"
else -> "application/$fileExtension"
},
mediaSource = MediaSource(url = "", json = ""),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
fileSize = fileSize,
formattedFileSize = formattedFileSize,
@@ -18,8 +18,6 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
aTimelineItemFileContent(caption = "A caption"),
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
@@ -10,13 +10,27 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
// State of the call, for now only isDeclined but in the future could be missed, active.
sealed interface RtcNotificationState {
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState
/** Call is currently active. **/
data class Active(
val joinedMembers: List<MatrixUser>,
val isJoined: Boolean,
val callStartTsMillis: Long?,
val callIntent: CallIntent,
) : RtcNotificationState
object Started : RtcNotificationState
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState, Tombstoned
object Started : RtcNotificationState, Tombstoned
/**
* Tag for Tombstoned/Past calls.
*/
interface Tombstoned
}
class TimelineItemRtcNotificationContent(
@@ -20,6 +20,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
aTimelineItemVideoContent(),
aTimelineItemVideoContent(aspectRatio = 1.0f),
aTimelineItemVideoContent(aspectRatio = 1.5f),
aTimelineItemVideoContent(thumbnailMediaSource = MediaSource("https://example.com/thumbnail.jpg")),
aTimelineItemVideoContent(blurhash = null),
)
}
@@ -27,17 +28,20 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent(
aspectRatio: Float = 0.5f,
blurhash: String? = A_BLUR_HASH,
caption: String? = null,
mediaSource: MediaSource = MediaSource(""),
thumbnailMediaSource: MediaSource? = null,
) = TimelineItemVideoContent(
filename = "Video.mp4",
fileSize = 14 * 1024 * 1024L,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
thumbnailSource = thumbnailMediaSource,
blurHash = blurhash,
aspectRatio = aspectRatio,
duration = 100.milliseconds,
mediaSource = MediaSource(""),
mediaSource = mediaSource,
width = 150,
height = 300,
thumbnailWidth = 150,
@@ -9,7 +9,13 @@
package io.element.android.features.messages.impl.timeline.protection
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
sealed interface TimelineProtectionEvent {
data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent
data class ValidateContent(
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState,
) : TimelineProtectionEvent
}
@@ -12,37 +12,40 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom
import kotlinx.collections.immutable.toImmutableSet
@SingleIn(RoomScope::class)
@Inject
class TimelineProtectionPresenter(
private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom,
private val contentScannerService: ContentScannerService,
private val timelineProtectionStore: TimelineProtectionStore,
) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
@Composable
override fun present(): TimelineProtectionState {
val mediaPreviewValue = remember {
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
}.collectAsState()
val roomInfo = room.roomInfoFlow.collectAsState()
val protectionState by remember {
derivedStateOf {
val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule)
if (isPreviewEnabled) {
ProtectionState.RenderAll
} else {
ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet())
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
}
}
}
@@ -50,7 +53,10 @@ class TimelineProtectionPresenter(
fun handleEvent(event: TimelineProtectionEvent) {
when (event) {
is TimelineProtectionEvent.ShowContent -> {
allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId)
event.eventId?.let(timelineProtectionStore::allowEvent)
}
is TimelineProtectionEvent.ValidateContent -> {
contentScannerService.scan(event.mediaSources, event.validationState)
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.protection
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.snapshots.SnapshotStateSet
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
interface TimelineProtectionStore {
val allowedEventIds: SnapshotStateSet<EventId>
fun allowEvent(eventId: EventId)
}
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultTimelineProtectionStore : TimelineProtectionStore {
private val allowedEvents = mutableStateSetOf<EventId>()
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
override fun allowEvent(eventId: EventId) {
allowedEvents += eventId
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.util
import coil3.compose.AsyncImagePainter
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import okio.IOException
fun handleAsyncImageStateChange(
state: AsyncImagePainter.State,
onLoaded: () -> Unit,
updateContentValidationState: ((ContentValidationValue) -> Unit)?,
) {
if (state is AsyncImagePainter.State.Success) {
onLoaded()
}
val updatedContentValidationValue = when (state) {
is AsyncImagePainter.State.Empty -> ContentValidationValue.Unknown
is AsyncImagePainter.State.Loading -> ContentValidationValue.Loading
is AsyncImagePainter.State.Success -> ContentValidationValue.Valid
is AsyncImagePainter.State.Error -> {
when (val cause = state.result.throwable) {
is ClientException.ContentScanner if cause.reason.isDangerous() -> ContentValidationValue.Invalid
is IOException -> {
// Use `Unknown` instead of `Invalid` to allow retrying the download later
ContentValidationValue.Unknown
}
else -> ContentValidationValue.UnrecoverableError(cause)
}
}
}
updateContentValidationState?.invoke(updatedContentValidationValue)
}
@@ -14,7 +14,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,7 +30,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -46,9 +49,10 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -65,13 +69,14 @@ internal fun MessagesViewTopBar(
heroes: ImmutableList<AvatarData>,
dmUserIdentityState: IdentityState?,
sharedHistoryIcon: SharedHistoryIcon,
dmUserStatus: DisplayedStatus?,
onRoomDetailsClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
menuActions: @Composable RowScope.() -> Unit,
) {
TopAppBar(
modifier = modifier,
modifier = modifier.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()),
navigationIcon = {
BackButton(onClick = onBackClick)
},
@@ -91,6 +96,7 @@ internal fun MessagesViewTopBar(
roomAvatar = roomAvatar,
isTombstoned = isTombstoned,
heroes = heroes,
dmUserStatus = dmUserStatus,
modifier = titleModifier
)
@@ -144,6 +150,7 @@ private fun RoomAvatarAndNameRow(
roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier
) {
Row(
@@ -157,14 +164,13 @@ private fun RoomAvatarAndNameRow(
isTombstoned = isTombstoned,
),
)
Text(
modifier = Modifier
.padding(start = 8.dp),
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
DisplayNameWithStatus(
name = roomName ?: stringResource(CommonStrings.common_no_room_name),
status = dmUserStatus,
modifier = Modifier.padding(start = 8.dp),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
maxLines = 1,
overflow = TextOverflow.Ellipsis
nameColor = ElementTheme.colors.textPrimary,
nameFontStyle = FontStyle.Italic.takeIf { roomName == null },
)
}
}
@@ -184,6 +190,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null,
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
dmUserStatus: DisplayedStatus? = null,
displayThreads: Boolean = false,
) = MessagesViewTopBar(
roomName = roomName,
@@ -192,6 +199,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes = heroes,
dmUserIdentityState = dmUserIdentityState,
sharedHistoryIcon = sharedHistoryIcon,
dmUserStatus = dmUserStatus,
onRoomDetailsClick = {},
onBackClick = {},
menuActions = {
@@ -246,5 +254,11 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
AMessagesViewTopBar(
displayThreads = true,
)
HorizontalDivider()
AMessagesViewTopBar(
roomName = "A DM with a very very very long name",
dmUserIdentityState = IdentityState.Verified,
dmUserStatus = DisplayedStatus.InCall(0L)
)
}
}
@@ -28,6 +28,8 @@ import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.view.spans.CodeBlockSpan
import io.element.android.wysiwyg.view.spans.InlineCodeSpan
private val MATRIX_URI_REGEX = Regex("""matrix:(?:u|user|r|room|roomid|e|event)/\S*[^\s.,;:!?)]""", RegexOption.IGNORE_CASE)
interface TextPillificationHelper {
fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence
}
@@ -87,6 +89,7 @@ class DefaultTextPillificationHelper(
}
private fun pillifyPermalinks(text: SpannableStringBuilder) {
pillifyMatrixUris(text)
for (match in Patterns.WEB_URL.toRegex().findAll(text)) {
val start = match.range.first
val end = match.range.last + 1
@@ -99,6 +102,21 @@ class DefaultTextPillificationHelper(
}
}
private fun pillifyMatrixUris(text: SpannableStringBuilder) {
for (match in MATRIX_URI_REGEX.findAll(text)) {
val start = match.range.first
val end = match.range.last + 1
if (!text.canPillify(start, end)) continue
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, match.value)
if (mentionSpan != null) {
text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (text.getSpans<URLSpan>(start, end).isEmpty()) {
text.setSpan(URLSpan(match.value), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
}
private fun Spanned.canPillify(start: Int, end: Int): Boolean {
if (getMentionSpans(start, end).isNotEmpty()) return false
if (getSpans<CodeBlockSpan>(start, end).isNotEmpty()) return false
@@ -77,7 +77,8 @@ class DefaultMessageSummaryFormatter(
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
RtcNotificationState.Started, is RtcNotificationState.Active
-> context.getString(CommonStrings.common_call_started)
}
}
// Truncate the message to a safe length to avoid crashes in Compose
@@ -6,14 +6,6 @@
<string name="crypto_event_authenticity_unknown_device">"Зашыфравана невядомай ці выдаленай прыладай."</string>
<string name="crypto_event_authenticity_unsigned_device">"Зашыфравана прыладай, не пацверджанай яе ўладальнікам."</string>
<string name="crypto_event_authenticity_unverified_identity">"Зашыфравана неправераным карыстальнікам."</string>
<string name="emoji_picker_category_activity">"Актыўнасці"</string>
<string name="emoji_picker_category_flags">"Сцягі"</string>
<string name="emoji_picker_category_foods">"Ежа &amp; Напоі"</string>
<string name="emoji_picker_category_nature">"Жывёлы &amp; Прырода"</string>
<string name="emoji_picker_category_objects">"Аб\'екты"</string>
<string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string>
<string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string>
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_processing">"Апрацоўка…"</string>
@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_sent_in_clear">"Без шифроване"</string>
<string name="emoji_picker_category_activity">"Дейности"</string>
<string name="emoji_picker_category_flags">"Знамена"</string>
<string name="emoji_picker_category_foods">"Храна &amp; Напитки"</string>
<string name="emoji_picker_category_nature">"Животни &amp; Природа"</string>
<string name="emoji_picker_category_objects">"Обекти"</string>
<string name="emoji_picker_category_people">"Усмивки &amp; Хора"</string>
<string name="emoji_picker_category_places">"Пътуване &amp; Места"</string>
<string name="emoji_picker_category_symbols">"Символи"</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Докоснете, за да промените качеството на качване на видео"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файлът не можа да бъде качен."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Неуспешна обработка на мултимедия за качване, моля, опитайте отново."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Xifrat per un dispositiu desconegut o eliminat."</string>
<string name="crypto_event_authenticity_unsigned_device">"Xifrat per un dispositiu no verificat pel seu propietari."</string>
<string name="crypto_event_authenticity_unverified_identity">"Xifrat per un usuari no verificat."</string>
<string name="emoji_picker_category_activity">"Activitats"</string>
<string name="emoji_picker_category_flags">"Banderes"</string>
<string name="emoji_picker_category_foods">"Menjar &amp; begudes"</string>
<string name="emoji_picker_category_nature">"Animals i naturalesa"</string>
<string name="emoji_picker_category_objects">"Objectes"</string>
<string name="emoji_picker_category_people">"Cares i persones"</string>
<string name="emoji_picker_category_places">"Viatges i llocs"</string>
<string name="emoji_picker_category_recent">"Emoticones recents"</string>
<string name="emoji_picker_category_symbols">"Símbols"</string>
<string name="screen_media_upload_preview_caption_warning">"És possible que les llegendes no siguin visibles pels que utilitzin aplicacions antigues."</string>
<string name="screen_media_upload_preview_error_failed_processing">"No s\'ha pogut processar el contingut que s\'havia de pujar. Torna-ho a provar."</string>
<string name="screen_media_upload_preview_error_failed_sending">"No s\'ha pogut pujar el contingut. Torna-ho a provar."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
<string name="crypto_event_authenticity_unsigned_device">"Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem."</string>
<string name="crypto_event_authenticity_unverified_identity">"Šifrováno neověřeným uživatelem."</string>
<string name="emoji_picker_category_activity">"Aktivity"</string>
<string name="emoji_picker_category_flags">"Vlajky"</string>
<string name="emoji_picker_category_foods">"Jídlo a nápoje"</string>
<string name="emoji_picker_category_nature">"Zvířata a příroda"</string>
<string name="emoji_picker_category_objects">"Předměty"</string>
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
<string name="emoji_picker_category_places">"Cestování a místa"</string>
<string name="emoji_picker_category_recent">"Nedávné emotikony"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klepnutím změníte kvalitu nahrávání videa"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Soubor nelze nahrát."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Wedi\'i amgryptio gan ddyfais anhysbys neu wedi\'i dileu."</string>
<string name="crypto_event_authenticity_unsigned_device">"Wedi\'i amgryptio gan ddyfais nad yw wedi\'i wirio gan ei pherchennog."</string>
<string name="crypto_event_authenticity_unverified_identity">"Wedi\'i amgryptio gan ddefnyddiwr heb ei wirio."</string>
<string name="emoji_picker_category_activity">"Gweithgareddau"</string>
<string name="emoji_picker_category_flags">"Baneri"</string>
<string name="emoji_picker_category_foods">"Bwyd a Diod"</string>
<string name="emoji_picker_category_nature">"Anifeiliaid a Natur"</string>
<string name="emoji_picker_category_objects">"Gwrthrychau"</string>
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</string>
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
<string name="emoji_picker_category_recent">"Emojis diweddar"</string>
<string name="emoji_picker_category_symbols">"Symbolau"</string>
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tapiwch i newid ansawdd llwytho\'r fideo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nid oedd modd llwytho\'r ffeil."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Krypteret af en ukendt eller slettet enhed."</string>
<string name="crypto_event_authenticity_unsigned_device">"Krypteret af en enhed, der ikke er verificeret af sin ejer."</string>
<string name="crypto_event_authenticity_unverified_identity">"Krypteret af en ikke-verificeret bruger."</string>
<string name="emoji_picker_category_activity">"Aktiviteter"</string>
<string name="emoji_picker_category_flags">"Flag"</string>
<string name="emoji_picker_category_foods">"Mad og drikke"</string>
<string name="emoji_picker_category_nature">"Dyr og natur"</string>
<string name="emoji_picker_category_objects">"Objekter"</string>
<string name="emoji_picker_category_people">"Smileys og personer"</string>
<string name="emoji_picker_category_places">"Rejser og steder"</string>
<string name="emoji_picker_category_recent">"Seneste emojis"</string>
<string name="emoji_picker_category_symbols">"Symboler"</string>
<string name="screen_media_upload_preview_caption_warning">"Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tryk for at ændre videokvaliteten i uploadet"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunne ikke uploades."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Verschlüsselt von einem unbekannten oder gelöschten Gerät."</string>
<string name="crypto_event_authenticity_unsigned_device">"Verschlüsselt durch ein Gerät, das nicht von seinem Besitzer verifiziert wurde."</string>
<string name="crypto_event_authenticity_unverified_identity">"Verschlüsselt durch einen nicht verifizierten Nutzer."</string>
<string name="emoji_picker_category_activity">"Aktivitäten"</string>
<string name="emoji_picker_category_flags">"Flaggen"</string>
<string name="emoji_picker_category_foods">"Essen &amp; Trinken"</string>
<string name="emoji_picker_category_nature">"Tiere &amp; Natur"</string>
<string name="emoji_picker_category_objects">"Objekte"</string>
<string name="emoji_picker_category_people">"Smileys &amp; Menschen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; Orte"</string>
<string name="emoji_picker_category_recent">"Zuletzt verwendete Emojis"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tippe, um die Qualität des Video-Uploads zu ändern"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Die Datei konnte nicht hochgeladen werden."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή."</string>
<string name="crypto_event_authenticity_unsigned_device">"Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της."</string>
<string name="crypto_event_authenticity_unverified_identity">"Κρυπτογραφημένο από μη επαληθευμένο χρήστη."</string>
<string name="emoji_picker_category_activity">"Δραστηριότητες"</string>
<string name="emoji_picker_category_flags">"Σημαίες"</string>
<string name="emoji_picker_category_foods">"Φαγητό &amp; Ποτό"</string>
<string name="emoji_picker_category_nature">"Ζώα &amp; Φύση"</string>
<string name="emoji_picker_category_objects">"Αντικείμενα"</string>
<string name="emoji_picker_category_people">"Φατσούλες &amp; Άνθρωποι"</string>
<string name="emoji_picker_category_places">"Ταξίδια &amp; Μέρη"</string>
<string name="emoji_picker_category_recent">"Πρόσφατα emoji"</string>
<string name="emoji_picker_category_symbols">"Σύμβολα"</string>
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Δεν ήταν δυνατή η μεταφόρτωση του αρχείου."</string>
@@ -7,14 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Cifrado por un dispositivo desconocido o eliminado."</string>
<string name="crypto_event_authenticity_unsigned_device">"Cifrado por un dispositivo no verificado por su propietario."</string>
<string name="crypto_event_authenticity_unverified_identity">"Cifrado por un usuario no verificado."</string>
<string name="emoji_picker_category_activity">"Actividades"</string>
<string name="emoji_picker_category_flags">"Banderas"</string>
<string name="emoji_picker_category_foods">"Comida y bebida"</string>
<string name="emoji_picker_category_nature">"Animales y naturaleza"</string>
<string name="emoji_picker_category_objects">"Objetos"</string>
<string name="emoji_picker_category_people">"Emojis y personas"</string>
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Error al procesar el contenido multimedia, por favor inténtalo de nuevo."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Error al subir el contenido multimedia, por favor inténtalo de nuevo."</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Krüptitud tundmatu või kustutatud seadme poolt."</string>
<string name="crypto_event_authenticity_unsigned_device">"Krüptitud seadme poolt, mida tema omanik pole verifitseerinud."</string>
<string name="crypto_event_authenticity_unverified_identity">"Krüptitud verifitseerimata kasutaja poolt."</string>
<string name="emoji_picker_category_activity">"Tegevused"</string>
<string name="emoji_picker_category_flags">"Lipud"</string>
<string name="emoji_picker_category_foods">"Toit ja jook"</string>
<string name="emoji_picker_category_nature">"Loomad ja loodus"</string>
<string name="emoji_picker_category_objects">"Esemed"</string>
<string name="emoji_picker_category_people">"Emotikonid ja inimesed"</string>
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
<string name="emoji_picker_category_recent">"Hiljutised emojid"</string>
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Pööra pilti ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Pilt on pööratud ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Pildi algne variant"</string>
@@ -79,6 +70,10 @@
<string name="screen_room_timeline_reactions_show_more">"Näita rohkem"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Näita reageerimiste kokkuvõtet"</string>
<string name="screen_room_timeline_read_marker_title">"Uus"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d eemaldas sõnumi"</item>
<item quantity="other">"%1$d eemaldas sõnumid"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d jututoa muudatus"</item>
<item quantity="other">"%1$d jututoa muudatust"</item>
@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_sent_in_clear">"Ez dago zifratuta."</string>
<string name="emoji_picker_category_activity">"Jarduerak"</string>
<string name="emoji_picker_category_flags">"Banderak"</string>
<string name="emoji_picker_category_foods">"Jana eta edana"</string>
<string name="emoji_picker_category_nature">"Animaliak eta natura"</string>
<string name="emoji_picker_category_objects">"Objektuak"</string>
<string name="emoji_picker_category_people">"Irribartxoak eta jendea"</string>
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
<string name="emoji_picker_category_symbols">"Ikurrak"</string>
<string name="screen_media_upload_preview_error_failed_sending">"Huts egin du multimedia igotzeak, saiatu berriro."</string>
<string name="screen_report_content_block_user">"Blokeatu erabiltzailea"</string>
<string name="screen_report_content_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
@@ -6,15 +6,6 @@
<string name="crypto_event_authenticity_unknown_device">"رمز شده به دست افزاره‌ای ناشناخته یا حذف شده."</string>
<string name="crypto_event_authenticity_unsigned_device">"رمز شده به دست افزاره‌ای که از سوی مالکش تأیید نشده."</string>
<string name="crypto_event_authenticity_unverified_identity">"رمز شده به دست کاربری تأیید نشده."</string>
<string name="emoji_picker_category_activity">"فعّالیت‌ها"</string>
<string name="emoji_picker_category_flags">"پرچم‌ها"</string>
<string name="emoji_picker_category_foods">"غذا و نوشیدنی"</string>
<string name="emoji_picker_category_nature">"حیوانات و طبعیت"</string>
<string name="emoji_picker_category_objects">"اشیا"</string>
<string name="emoji_picker_category_people">"شکلک‌ها و افراد"</string>
<string name="emoji_picker_category_places">"سفر و مکان‌ها"</string>
<string name="emoji_picker_category_recent">"شکلک‌های اخیر"</string>
<string name="emoji_picker_category_symbols">"نمادها"</string>
<string name="screen_media_upload_preview_error_failed_processing">"پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید."</string>
<string name="screen_media_upload_preview_error_failed_sending">"بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید."</string>
<string name="screen_media_upload_preview_processing">"پردازش کردن…"</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Tuntemattoman tai poistetun laitteen salaama."</string>
<string name="crypto_event_authenticity_unsigned_device">"Salattu laitteella, jota sen omistaja ei ole vahvistanut."</string>
<string name="crypto_event_authenticity_unverified_identity">"Vahvistamattoman käyttäjän salaama."</string>
<string name="emoji_picker_category_activity">"Aktiviteetit"</string>
<string name="emoji_picker_category_flags">"Liput"</string>
<string name="emoji_picker_category_foods">"Ruoka ja juoma"</string>
<string name="emoji_picker_category_nature">"Eläimet ja luonto"</string>
<string name="emoji_picker_category_objects">"Esineet"</string>
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</string>
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Käännä kuva vaakasuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Käännetty vaakasuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Alkuperäinen"</string>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Chiffré par un appareil inconnu ou supprimé."</string>
<string name="crypto_event_authenticity_unsigned_device">"Chiffré par un appareil non vérifié par son propriétaire."</string>
<string name="crypto_event_authenticity_unverified_identity">"Chiffré par un utilisateur non vérifié."</string>
<string name="emoji_picker_category_activity">"Activités"</string>
<string name="emoji_picker_category_flags">"Drapeaux"</string>
<string name="emoji_picker_category_foods">"Nourriture et boissons"</string>
<string name="emoji_picker_category_nature">"Animaux et nature"</string>
<string name="emoji_picker_category_objects">"Objets"</string>
<string name="emoji_picker_category_people">"Émoticônes et personnes"</string>
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_recent">"Emojis récents"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
@@ -7,15 +7,18 @@
<string name="crypto_event_authenticity_unknown_device">"Šifrirano nepoznatim ili izbrisanim uređajem."</string>
<string name="crypto_event_authenticity_unsigned_device">"Šifrirano uređajem koji vlasnik nije potvrdio."</string>
<string name="crypto_event_authenticity_unverified_identity">"Šifrirao neprovjereni korisnik."</string>
<string name="emoji_picker_category_activity">"Aktivnosti"</string>
<string name="emoji_picker_category_flags">"Zastave"</string>
<string name="emoji_picker_category_foods">"Hrana i piće"</string>
<string name="emoji_picker_category_nature">"Životinje i priroda"</string>
<string name="emoji_picker_category_objects">"Objekti"</string>
<string name="emoji_picker_category_people">"Emotikoni i osobe"</string>
<string name="emoji_picker_category_places">"Putovanja i mjesta"</string>
<string name="emoji_picker_category_recent">"Nedavni emotikoni"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Okreni sliku vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Okrenuto vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Izvornik"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Okreni sliku okomito"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Okrenuto okomito"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Izvornik"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Okrenite sliku ulijevo"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d stupanj"</item>
<item quantity="other">"%1$d stupnjevi"</item>
</plurals>
<string name="screen_image_edition_title">"Uredi fotografiju"</string>
<string name="screen_media_upload_preview_caption_warning">"Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Dodirnite za promjenu kvalitete prijenosa videozapisa"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Datoteka se nije mogla prenijeti."</string>
@@ -26,6 +29,7 @@
<string name="screen_media_upload_preview_item_count">"Stavka %1$d od %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiziraj kvalitetu slike"</string>
<string name="screen_media_upload_preview_processing">"Obrada…"</string>
<string name="screen_media_upload_preview_title">"Dodaj medij"</string>
<string name="screen_report_content_block_user">"Blokiraj korisnika"</string>
<string name="screen_report_content_block_user_hint">"Označite ako želite sakriti sve trenutačne i buduće poruke od ovog korisnika"</string>
<string name="screen_report_content_explanation">"Ova poruka bit će prijavljena administratoru vašeg matičnog poslužitelja. On neće moći pročitati nijednu šifriranu poruku."</string>
@@ -68,6 +72,10 @@
<string name="screen_room_timeline_reactions_show_more">"Prikaži više"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Prikaži sažetak reakcija"</string>
<string name="screen_room_timeline_read_marker_title">"Novo"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d uklonjena poruka"</item>
<item quantity="other">"%1$d uklonjene poruke"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d promjena sobe"</item>
<item quantity="few">"%1$d promjene sobe"</item>
@@ -7,15 +7,6 @@
<string name="crypto_event_authenticity_unknown_device">"Ismeretlen vagy törölt eszköz által titkosítva."</string>
<string name="crypto_event_authenticity_unsigned_device">"A tulajdonos által nem ellenőrzött eszköz által titkosítva."</string>
<string name="crypto_event_authenticity_unverified_identity">"Nem ellenőrzött felhasználó által titkosítva."</string>
<string name="emoji_picker_category_activity">"Tevékenységek"</string>
<string name="emoji_picker_category_flags">"Zászlók"</string>
<string name="emoji_picker_category_foods">"Étel és ital"</string>
<string name="emoji_picker_category_nature">"Állatok és természet"</string>
<string name="emoji_picker_category_objects">"Tárgyak"</string>
<string name="emoji_picker_category_people">"Mosolyok és emberek"</string>
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
<string name="emoji_picker_category_recent">"Legutóbbi emodzsik"</string>
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Kép vízszintes tükrözése"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Vízszintesen tükrözve"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Eredeti"</string>
@@ -79,6 +70,10 @@
<string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string>
<string name="screen_room_timeline_read_marker_title">"Új"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d eltávolított üzenet"</item>
<item quantity="other">"%1$d eltávolított üzenet"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d szobaváltozás"</item>
<item quantity="other">"%1$d szobaváltozás"</item>
@@ -7,15 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Dienkripsi oleh perangkat yang tidak dikenal atau dihapus."</string>
<string name="crypto_event_authenticity_unsigned_device">"Dienkripsi oleh perangkat yang tidak diverifikasi oleh pemiliknya."</string>
<string name="crypto_event_authenticity_unverified_identity">"Dienkripsi oleh pengguna yang tidak terverifikasi."</string>
<string name="emoji_picker_category_activity">"Aktivitas"</string>
<string name="emoji_picker_category_flags">"Bendera"</string>
<string name="emoji_picker_category_foods">"Makanan &amp; Minuman"</string>
<string name="emoji_picker_category_nature">"Hewan &amp; Alam"</string>
<string name="emoji_picker_category_objects">"Objek"</string>
<string name="emoji_picker_category_people">"Senyuman &amp; Orang"</string>
<string name="emoji_picker_category_places">"Wisata &amp; Tempat"</string>
<string name="emoji_picker_category_recent">"Emojis Sebelumnya"</string>
<string name="emoji_picker_category_symbols">"Simbol"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Putar gambar ke kiri"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d derajat"</item>
</plurals>
<string name="screen_image_edition_title">"Sunting Foto"</string>
<string name="screen_media_upload_preview_caption_warning">"Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Ketuk untuk mengubah kualitas unggahan video"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Dokumen tidak dapat diunggah."</string>
@@ -26,6 +22,7 @@
<string name="screen_media_upload_preview_item_count">"Item %1$d dari %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalkan kualitas gambar"</string>
<string name="screen_media_upload_preview_processing">"Memproses…"</string>
<string name="screen_media_upload_preview_title">"Tambahkan media"</string>
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
<string name="screen_report_content_block_user_hint">"Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini"</string>
<string name="screen_report_content_explanation">"Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun."</string>
@@ -7,15 +7,18 @@
<string name="crypto_event_authenticity_unknown_device">"Cifrato da un dispositivo sconosciuto o eliminato."</string>
<string name="crypto_event_authenticity_unsigned_device">"Cifrato da un dispositivo non verificato dal proprietario."</string>
<string name="crypto_event_authenticity_unverified_identity">"Cifrato da un utente non verificato."</string>
<string name="emoji_picker_category_activity">"Attività"</string>
<string name="emoji_picker_category_flags">"Bandiere"</string>
<string name="emoji_picker_category_foods">"Cibi &amp; Bevande"</string>
<string name="emoji_picker_category_nature">"Animali &amp; Natura"</string>
<string name="emoji_picker_category_objects">"Oggetti"</string>
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Capovolgi l\'immagine orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Capovolto orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Capovolgi l\'immagine verticalmente"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Capovolto verticalmente"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Ruota l\'immagine verso sinistra"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d grado"</item>
<item quantity="other">"%1$d gradi"</item>
</plurals>
<string name="screen_image_edition_title">"Modifica foto"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tocca per modificare la qualità di caricamento del video"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Impossibile caricare il file."</string>
@@ -26,6 +29,7 @@
<string name="screen_media_upload_preview_item_count">"Elemento %1$d di %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Ottimizza la qualità delle immagini"</string>
<string name="screen_media_upload_preview_processing">"Elaborazione…"</string>
<string name="screen_media_upload_preview_title">"Aggiungi contenuti multimediali"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati."</string>
@@ -34,7 +38,7 @@
<string name="screen_room_attachment_source_camera_photo">"Scatta foto"</string>
<string name="screen_room_attachment_source_camera_video">"Registra video"</string>
<string name="screen_room_attachment_source_files">"Allegato"</string>
<string name="screen_room_attachment_source_gallery">"Libreria di foto e video"</string>
<string name="screen_room_attachment_source_gallery">"Libreria foto e video"</string>
<string name="screen_room_attachment_source_location">"Condividi posizione"</string>
<string name="screen_room_attachment_source_poll">"Sondaggio"</string>
<string name="screen_room_attachment_text_formatting">"Formattazione del testo"</string>
@@ -7,15 +7,12 @@
<string name="crypto_event_authenticity_unknown_device">"削除されたまたは不明な端末により暗号化されています。"</string>
<string name="crypto_event_authenticity_unsigned_device">"所有者に検証されていない端末により暗号化されています。"</string>
<string name="crypto_event_authenticity_unverified_identity">"未検証のユーザーにより暗号化されています。"</string>
<string name="emoji_picker_category_activity">"アクティビティ"</string>
<string name="emoji_picker_category_flags">"旗"</string>
<string name="emoji_picker_category_foods">"食べ物"</string>
<string name="emoji_picker_category_nature">"動物・自然"</string>
<string name="emoji_picker_category_objects">"物"</string>
<string name="emoji_picker_category_people">"顔・人"</string>
<string name="emoji_picker_category_places">"旅・場所"</string>
<string name="emoji_picker_category_recent">"最近使用"</string>
<string name="emoji_picker_category_symbols">"記号"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平に反転"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"垂直に反転"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"元画像"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直に反転"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"水平に反転"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"編集前"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"画像を左に回転"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d°"</item>

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