mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into misc/reactions-pinned-messages
This commit is contained in:
+6
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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(
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
)
|
||||
|
||||
+32
-15
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-5
@@ -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)
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -102,6 +102,7 @@ internal fun MessageComposerView(
|
||||
onRequestFocus = ::onRequestFocus,
|
||||
onSendMessage = ::sendMessage,
|
||||
composerMode = state.mode,
|
||||
isInThreadTimeline = state.isInThreadTimeline,
|
||||
showTextFormatting = state.showTextFormatting,
|
||||
onResetComposerMode = ::onCloseSpecialMode,
|
||||
onAddAttachment = ::onAddAttachment,
|
||||
|
||||
+13
-6
@@ -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(
|
||||
|
||||
+39
@@ -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
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+7
-1
@@ -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))
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+10
-5
@@ -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,
|
||||
|
||||
+13
@@ -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 = {},
|
||||
)
|
||||
|
||||
|
||||
+12
-3
@@ -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!",
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
+11
-1
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onJoinCallClick = {},
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
||||
+378
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
enum class ContentPadding {
|
||||
Textual,
|
||||
Media,
|
||||
CaptionedMedia
|
||||
CaptionedMedia,
|
||||
InvalidContent,
|
||||
}
|
||||
|
||||
+12
-1
@@ -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(
|
||||
|
||||
+1
@@ -166,6 +166,7 @@ internal fun MessageShieldViewPreview() {
|
||||
displayName = USER_NAME_ALICE,
|
||||
displayNameAmbiguous = false,
|
||||
avatarUrl = null,
|
||||
displayedStatus = null,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
+7
-5
@@ -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 = {},
|
||||
|
||||
+346
@@ -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
|
||||
)
|
||||
)
|
||||
+93
-37
@@ -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,
|
||||
)
|
||||
|
||||
+46
-10
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+26
-11
@@ -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)
|
||||
|
||||
+6
-2
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+48
-36
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-36
@@ -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
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-100
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -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
|
||||
}
|
||||
-149
@@ -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(),
|
||||
)
|
||||
}
|
||||
-13
@@ -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
|
||||
}
|
||||
-114
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-37
@@ -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>,
|
||||
)
|
||||
-84
@@ -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,
|
||||
)
|
||||
+22
-42
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<Timeline
|
||||
),
|
||||
aTimelineItemAttachmentsContent(
|
||||
body = "Files",
|
||||
caption = "Files mixed with media",
|
||||
attachments = listOf(
|
||||
anAttachmentItem(
|
||||
filename = "report.pdf",
|
||||
|
||||
+111
-58
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+253
-106
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+63
-46
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+83
-141
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+37
-96
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
) {
|
||||
|
||||
+12
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ private fun ReadReceiptBottomSheetContent(
|
||||
LazyColumn {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = CommonStrings.common_seen_by))
|
||||
}
|
||||
)
|
||||
|
||||
+26
-5
@@ -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
|
||||
|
||||
+11
-3
@@ -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 -> {
|
||||
|
||||
+8
@@ -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.
|
||||
|
||||
+56
-1
@@ -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
|
||||
}
|
||||
|
||||
-2
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
-2
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -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(
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+6
@@ -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
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+24
-10
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+2
-1
@@ -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">"Ежа & Напоі"</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_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">"Храна & Напитки"</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_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 & 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 & Trinken"</string>
|
||||
<string name="emoji_picker_category_nature">"Tiere & Natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Objekte"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys & Menschen"</string>
|
||||
<string name="emoji_picker_category_places">"Reisen & 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">"Φαγητό & Ποτό"</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">"Πρόσφατα 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 & 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 & Minuman"</string>
|
||||
<string name="emoji_picker_category_nature">"Hewan & Alam"</string>
|
||||
<string name="emoji_picker_category_objects">"Objek"</string>
|
||||
<string name="emoji_picker_category_people">"Senyuman & Orang"</string>
|
||||
<string name="emoji_picker_category_places">"Wisata & 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 & Bevande"</string>
|
||||
<string name="emoji_picker_category_nature">"Animali & Natura"</string>
|
||||
<string name="emoji_picker_category_objects">"Oggetti"</string>
|
||||
<string name="emoji_picker_category_people">"Faccine & Persone"</string>
|
||||
<string name="emoji_picker_category_places">"Viaggi & 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
Reference in New Issue
Block a user