From dd2a1b3388e558b38c83e8a5de992dc729040e2b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 2 Oct 2024 19:47:44 +0200 Subject: [PATCH] Add settings to hide images and videos in the timeline. Hide images, videos and stickers in the timeline. Disable click on hidden content. It must be revealed first. Add preview without BlurHash. Also hide image in thumbnails. --- .../messages/impl/MessagesPresenter.kt | 20 ++- .../features/messages/impl/MessagesState.kt | 2 + .../messages/impl/MessagesStateProvider.kt | 4 + .../features/messages/impl/MessagesView.kt | 1 + .../MessageComposerPresenter.kt | 14 +- .../list/PinnedMessagesListPresenter.kt | 9 +- .../pinned/list/PinnedMessagesListState.kt | 2 + .../list/PinnedMessagesListStateProvider.kt | 4 + .../pinned/list/PinnedMessagesListView.kt | 11 +- .../impl/timeline/TimelinePresenter.kt | 2 +- .../messages/impl/timeline/TimelineView.kt | 5 + .../TimelineViewMessageShieldPreview.kt | 2 + .../components/ATimelineItemEventRow.kt | 4 + .../components/TimelineItemEventRow.kt | 16 +- .../TimelineItemGroupedEventsRow.kt | 13 ++ .../timeline/components/TimelineItemRow.kt | 14 +- .../components/TimelineItemStateEventRow.kt | 2 + .../event/TimelineItemAspectRatioBox.kt | 4 +- .../event/TimelineItemEventContentView.kt | 8 + .../components/event/TimelineItemImageView.kt | 58 +++++-- .../event/TimelineItemStickerView.kt | 72 ++++++-- .../components/event/TimelineItemVideoView.kt | 76 ++++++--- .../event/TimelineItemImageContentProvider.kt | 14 +- .../TimelineItemStickerContentProvider.kt | 14 +- .../event/TimelineItemVideoContentProvider.kt | 14 +- .../impl/timeline/protection/ProtectedView.kt | 33 ++++ .../impl/timeline/protection/TimelineItem.kt | 60 +++++++ .../protection/TimelineProtectionEvent.kt | 14 ++ .../protection/TimelineProtectionPresenter.kt | 53 ++++++ .../protection/TimelineProtectionState.kt | 28 ++++ .../TimelineProtectionStateProvider.kt | 16 ++ .../impl/developer/DeveloperSettingsEvents.kt | 1 + .../developer/DeveloperSettingsPresenter.kt | 7 + .../impl/developer/DeveloperSettingsState.kt | 1 + .../DeveloperSettingsStateProvider.kt | 2 + .../impl/developer/DeveloperSettingsView.kt | 19 ++- .../DeveloperSettingsPresenterTest.kt | 19 +++ .../developer/DeveloperSettingsViewTest.kt | 13 ++ .../ui/components/AttachmentThumbnail.kt | 2 +- .../ui/messages/reply/InReplyToMetadata.kt | 10 +- .../matrix/ui/messages/reply/InReplyToView.kt | 8 +- .../messages/reply/InReplyToMetadataKtTest.kt | 155 ++++++++++++++++-- .../api/store/AppPreferencesStore.kt | 3 + .../impl/store/DefaultAppPreferencesStore.kt | 13 ++ .../test/InMemoryAppPreferencesStore.kt | 10 ++ .../textcomposer/ComposerModeView.kt | 8 +- .../libraries/textcomposer/TextComposer.kt | 52 +++--- .../textcomposer/model/MessageComposerMode.kt | 3 +- 48 files changed, 775 insertions(+), 140 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index b0029fe4ee..274e94617d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -46,6 +46,8 @@ 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.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionPresenter +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus @@ -90,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor( private val composerPresenter: MessageComposerPresenter, private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter, timelinePresenterFactory: TimelinePresenter.Factory, + private val timelineProtectionPresenter: TimelineProtectionPresenter, private val actionListPresenterFactory: ActionListPresenter.Factory, private val customReactionPresenter: CustomReactionPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter, @@ -123,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor( val composerState = composerPresenter.present() val voiceMessageComposerState = voiceMessageComposerPresenter.present() val timelineState = timelinePresenter.present() + val timelineProtectionState = timelineProtectionPresenter.present() val actionListState = actionListPresenter.present() val customReactionState = customReactionPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present() @@ -182,6 +186,7 @@ class MessagesPresenter @AssistedInject constructor( composerState = composerState, enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, + timelineProtectionState = timelineProtectionState, ) } is MessagesEvents.ToggleReaction -> { @@ -213,6 +218,7 @@ class MessagesPresenter @AssistedInject constructor( userEventPermissions = userEventPermissions, voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, + timelineProtectionState = timelineProtectionState, actionListState = actionListState, customReactionState = customReactionState, reactionSummaryState = reactionSummaryState, @@ -262,6 +268,7 @@ class MessagesPresenter @AssistedInject constructor( action: TimelineItemAction, targetEvent: TimelineItem.Event, composerState: MessageComposerState, + timelineProtectionState: TimelineProtectionState, enableTextFormatting: Boolean, timelineState: TimelineState, ) = launch { @@ -271,7 +278,7 @@ class MessagesPresenter @AssistedInject constructor( TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.Reply, - TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState) + TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState) TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) TimelineItemAction.Forward -> handleForwardAction(targetEvent) TimelineItemAction.ReportContent -> handleReportAction(targetEvent) @@ -385,11 +392,18 @@ class MessagesPresenter @AssistedInject constructor( } } - private suspend fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { + private suspend fun handleActionReply( + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + timelineProtectionState: TimelineProtectionState, + ) { if (targetEvent.eventId == null) return timelineController.invokeOnCurrentTimeline { val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser) - val composerMode = MessageComposerMode.Reply(replyToDetails = replyToDetails) + val composerMode = MessageComposerMode.Reply( + replyToDetails = replyToDetails, + hideImage = timelineProtectionState.hideContent(targetEvent.eventId), + ) composerState.eventSink( MessageComposerEvents.SetMode(composerMode) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 2c5bae6d3b..2e03cbdb9d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -32,6 +33,7 @@ data class MessagesState( val composerState: MessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, + val timelineProtectionState: TimelineProtectionState, val actionListState: ActionListState, val customReactionState: CustomReactionState, val reactionSummaryState: ReactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 2c1a487440..985471c641 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -26,6 +26,8 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +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.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState @@ -103,6 +105,7 @@ fun aMessagesState( // Render a focused event for an event with sender information displayed focusedEventIndex = 2, ), + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), actionListState: ActionListState = anActionListState(), customReactionState: CustomReactionState = aCustomReactionState(), @@ -121,6 +124,7 @@ fun aMessagesState( userEventPermissions = userEventPermissions, composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, + timelineProtectionState = timelineProtectionState, timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 067d0da87b..36bf0bc5fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -379,6 +379,7 @@ private fun MessagesViewContent( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior() TimelineView( state = state.timelineState, + timelineProtectionState = state.timelineProtectionState, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onMessageClick = onMessageClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index aabeaa96a0..6b80605401 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -585,10 +585,20 @@ class MessageComposerPresenter @Inject constructor( content = htmlText ?: markdownText ) is ComposerDraftType.Reply -> { - messageComposerContext.composerMode = MessageComposerMode.Reply(InReplyToDetails.Loading(draftType.eventId)) + messageComposerContext.composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(draftType.eventId), + // I guess it's fine to always render the image when restoring a draft + hideImage = false + ) timelineController.invokeOnCurrentTimeline { val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser) - run { messageComposerContext.composerMode = MessageComposerMode.Reply(replyToDetails) } + run { + messageComposerContext.composerMode = MessageComposerMode.Reply( + replyToDetails = replyToDetails, + // I guess it's fine to always render the image when restoring a draft + hideImage = false + ) + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 1e557f84bd..7452e7b01a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionPresenter +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -60,6 +62,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( private val room: MatrixRoom, timelineItemsFactoryCreator: TimelineItemsFactory.Creator, private val timelineProvider: PinnedEventsTimelineProvider, + private val timelineProtectionPresenter: TimelineProtectionPresenter, private val snackbarDispatcher: SnackbarDispatcher, actionListPresenterFactory: ActionListPresenter.Factory, private val appCoroutineScope: CoroutineScope, @@ -97,14 +100,13 @@ class PinnedMessagesListPresenter @AssistedInject constructor( ) ) } - + val timelineProtectionState = timelineProtectionPresenter.present() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userEventPermissions by userEventPermissions(syncUpdateFlow.value) var pinnedMessageItems by remember { mutableStateOf>>(AsyncData.Uninitialized) } - PinnedMessagesListEffect( onItemsChange = { newItems -> pinnedMessageItems = newItems @@ -119,6 +121,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( return pinnedMessagesListState( timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, eventSink = ::handleEvents @@ -214,6 +217,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( @Composable private fun pinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, eventSink: (PinnedMessagesListEvents) -> Unit @@ -228,6 +232,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( val actionListState = actionListPresenter.present() PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index 82105a2e35..3a15c9f8bc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -26,6 +27,7 @@ sealed interface PinnedMessagesListState { data object Empty : PinnedMessagesListState data class Filled( val timelineRoomInfo: TimelineRoomInfo, + val timelineProtectionState: TimelineProtectionState, val userEventPermissions: UserEventPermissions, val timelineItems: ImmutableList, val actionListState: ActionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index d394b6efad..bd51652cb4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -83,12 +85,14 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty fun aLoadedPinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, eventSink: (PinnedMessagesListEvents) -> Unit = {} ) = PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, userEventPermissions = aUserEventPermissions, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index ad62ad616a..fc561d57e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -32,6 +32,8 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel 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.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 import io.element.android.libraries.designsystem.components.button.BackButton @@ -77,8 +79,8 @@ fun PinnedMessagesListView( onLinkClick = onLinkClick, onErrorDismiss = onBackClick, modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding), + .padding(padding) + .consumeWindowInsets(padding), ) } ) @@ -208,6 +210,7 @@ private fun PinnedMessagesListLoaded( timelineItem = timelineItem, timelineRoomInfo = state.timelineRoomInfo, renderReadReceipts = false, + timelineProtectionState = state.timelineProtectionState, isLastOutgoingMessage = false, focusedEventId = null, onUserDataClick = onUserDataClick, @@ -225,6 +228,7 @@ private fun PinnedMessagesListLoaded( eventContentView = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentViewWrapper( event = event, + timelineProtectionState = state.timelineProtectionState, onLinkClick = onLinkClick, modifier = contentModifier, onContentLayoutChange = onContentLayoutChange @@ -238,6 +242,7 @@ private fun PinnedMessagesListLoaded( @Composable private fun TimelineItemEventContentViewWrapper( event: TimelineItem.Event, + timelineProtectionState: TimelineProtectionState, onLinkClick: (String) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, @@ -251,6 +256,8 @@ private fun TimelineItemEventContentViewWrapper( } else { TimelineItemEventContentView( content = event.content, + hideContent = timelineProtectionState.hideContent(event.eventId), + onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, eventSink = { }, modifier = modifier, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 819f7a1f3a..b40e24b88a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -243,8 +243,8 @@ class TimelinePresenter @AssistedInject constructor( } } return TimelineState( - timelineRoomInfo = timelineRoomInfo, timelineItems = timelineItems, + timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, newEventState = newEventState.value, isLive = isLive, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0781cc3ece..63a913284d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -57,6 +57,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.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider +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.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -72,6 +74,7 @@ import kotlinx.coroutines.launch @Composable fun TimelineView( state: TimelineState, + timelineProtectionState: TimelineProtectionState, onUserDataClick: (UserId) -> Unit, onLinkClick: (String) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit, @@ -137,6 +140,7 @@ fun TimelineView( TimelineItemRow( timelineItem = timelineItem, timelineRoomInfo = state.timelineRoomInfo, + timelineProtectionState = timelineProtectionState, renderReadReceipts = state.renderReadReceipts, isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, @@ -320,6 +324,7 @@ internal fun TimelineViewPreview( ), focusedEventIndex = 0, ), + timelineProtectionState = aTimelineProtectionState(), onUserDataClick = {}, onLinkClick = {}, onMessageClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt index 4566bf88bc..806a81b7fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -14,6 +14,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import kotlinx.collections.immutable.toImmutableList @@ -35,6 +36,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview { timelineItems = items.toImmutableList(), messageShield = messageShield, ), + timelineProtectionState = aTimelineProtectionState(), onUserDataClick = {}, onLinkClick = {}, onMessageClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index 0db7a8ac52..1fa5e7f9a1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.Composable import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState // For previews @Composable @@ -20,10 +22,12 @@ internal fun ATimelineItemEventRow( renderReadReceipts: Boolean = false, isLastOutgoingMessage: Boolean = false, isHighlighted: Boolean = false, + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), ) = TimelineItemEventRow( event = event, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, + timelineProtectionState = timelineProtectionState, isLastOutgoingMessage = isLastOutgoingMessage, isHighlighted = isHighlighted, onClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 5a5fd6d470..bc710aeb92 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -70,6 +70,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -108,6 +110,7 @@ private val BUBBLE_INCOMING_OFFSET = 16.dp fun TimelineItemEventRow( event: TimelineItem.Event, timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, isHighlighted: Boolean, @@ -126,6 +129,8 @@ fun TimelineItemEventRow( eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> TimelineItemEventContentView( content = event.content, + hideContent = timelineProtectionState.hideContent(event.eventId), + onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, eventSink = eventSink, modifier = contentModifier, @@ -164,6 +169,7 @@ fun TimelineItemEventRow( } TimelineItemEventRowContent( event = event, + timelineProtectionState = timelineProtectionState, isHighlighted = isHighlighted, timelineRoomInfo = timelineRoomInfo, interactionSource = interactionSource, @@ -197,6 +203,7 @@ fun TimelineItemEventRow( } else { TimelineItemEventRowContent( event = event, + timelineProtectionState = timelineProtectionState, isHighlighted = isHighlighted, timelineRoomInfo = timelineRoomInfo, interactionSource = interactionSource, @@ -252,6 +259,7 @@ private fun SwipeSensitivity( @Composable private fun TimelineItemEventRowContent( event: TimelineItem.Event, + timelineProtectionState: TimelineProtectionState, isHighlighted: Boolean, timelineRoomInfo: TimelineRoomInfo, interactionSource: MutableInteractionSource, @@ -330,6 +338,7 @@ private fun TimelineItemEventRowContent( ) { MessageEventBubbleContent( event = event, + timelineProtectionState = timelineProtectionState, onMessageLongClick = onLongClick, inReplyToClick = inReplyToClick, eventSink = eventSink, @@ -411,6 +420,7 @@ private fun MessageSenderInformation( @Composable private fun MessageEventBubbleContent( event: TimelineItem.Event, + timelineProtectionState: TimelineProtectionState, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @@ -560,7 +570,11 @@ private fun MessageEventBubbleContent( .clip(RoundedCornerShape(6.dp)) // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent .clickable(onClick = inReplyToClick) - InReplyToView(inReplyTo, modifier = inReplyToModifier) + InReplyToView( + inReplyTo = inReplyTo, + hideImage = timelineProtectionState.hideContent(inReplyTo.eventId()), + modifier = inReplyToModifier, + ) } if (inReplyToDetails != null) { // Use SubComposeLayout only if necessary as it can have consequences on the performance. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 75f1ccc02c..eec5adcb4e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -25,6 +25,9 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.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 import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId @@ -34,6 +37,7 @@ import io.element.android.libraries.matrix.api.core.UserId fun TimelineItemGroupedEventsRow( timelineItem: TimelineItem.GroupedEvents, timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, focusedEventId: EventId?, @@ -52,6 +56,8 @@ fun TimelineItemGroupedEventsRow( { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( content = event.content, + hideContent = timelineProtectionState.hideContent(event.eventId), + onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, eventSink = eventSink, modifier = contentModifier, @@ -70,6 +76,7 @@ fun TimelineItemGroupedEventsRow( onExpandGroupClick = ::onExpandGroupClick, timelineItem = timelineItem, timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, focusedEventId = focusedEventId, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, @@ -94,6 +101,7 @@ private fun TimelineItemGroupedEventsRowContent( onExpandGroupClick: () -> Unit, timelineItem: TimelineItem.GroupedEvents, timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, @@ -112,6 +120,8 @@ private fun TimelineItemGroupedEventsRowContent( { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( content = event.content, + hideContent = timelineProtectionState.hideContent(event.eventId), + onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, eventSink = eventSink, modifier = contentModifier, @@ -136,6 +146,7 @@ private fun TimelineItemGroupedEventsRowContent( TimelineItemRow( timelineItem = subGroupEvent, timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, focusedEventId = focusedEventId, @@ -178,6 +189,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi onExpandGroupClick = {}, timelineItem = events, timelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState = aTimelineProtectionState(), focusedEventId = events.events.first().eventId, renderReadReceipts = true, isLastOutgoingMessage = false, @@ -202,6 +214,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi onExpandGroupClick = {}, timelineItem = aGroupedEvents(withReadReceipts = true), timelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState = aTimelineProtectionState(), focusedEventId = null, renderReadReceipts = true, isLastOutgoingMessage = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 56f509f9fb..35199837cc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -26,6 +26,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.mustBeProtected import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor import io.element.android.libraries.matrix.api.core.EventId @@ -37,6 +40,7 @@ internal fun TimelineItemRow( timelineRoomInfo: TimelineRoomInfo, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, + timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, onUserDataClick: (UserId) -> Unit, onLinkClick: (String) -> Unit, @@ -55,6 +59,8 @@ internal fun TimelineItemRow( { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( content = event.content, + hideContent = timelineProtectionState.hideContent(event.eventId), + onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, eventSink = eventSink, modifier = contentModifier, @@ -109,9 +115,14 @@ internal fun TimelineItemRow( event = timelineItem, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, + timelineProtectionState = timelineProtectionState, isLastOutgoingMessage = isLastOutgoingMessage, isHighlighted = timelineItem.isEvent(focusedEventId), - onClick = { onClick(timelineItem) }, + onClick = if (timelineProtectionState.hideContent(timelineItem.eventId) && timelineItem.mustBeProtected()) { + {} + } else { + { onClick(timelineItem) } + }, onLongClick = { onLongClick(timelineItem) }, onLinkClick = onLinkClick, onUserDataClick = onUserDataClick, @@ -133,6 +144,7 @@ internal fun TimelineItemRow( TimelineItemGroupedEventsRow( timelineItem = timelineItem, timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, focusedEventId = focusedEventId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index ffb398a0d1..2e3812a09d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -71,6 +71,8 @@ fun TimelineItemStateEventRow( TimelineItemEventContentView( content = event.content, onLinkClick = {}, + hideContent = false, + onShowClick = {}, eventSink = eventSink, modifier = Modifier.defaultTimelineContentPadding() ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt index 48072ce5bf..76777f4ae1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt @@ -25,12 +25,14 @@ fun TimelineItemAspectRatioBox( aspectRatio: Float?, modifier: Modifier = Modifier, contentAlignment: Alignment = Alignment.TopStart, + minHeight: Int = MIN_HEIGHT_IN_DP, + maxHeight: Int = MAX_HEIGHT_IN_DP, content: @Composable (BoxScope.() -> Unit), ) { val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO Box( modifier = modifier - .heightIn(min = MIN_HEIGHT_IN_DP.dp, max = MAX_HEIGHT_IN_DP.dp) + .heightIn(min = minHeight.dp, max = maxHeight.dp) .aspectRatio(safeAspectRatio, false), contentAlignment = contentAlignment, content = content diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 49b8731ae0..c855714d14 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -35,6 +35,8 @@ import io.element.android.libraries.architecture.Presenter @Composable fun TimelineItemEventContentView( content: TimelineItemEventContent, + hideContent: Boolean, + onShowClick: () -> Unit, onLinkClick: (url: String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, @@ -69,15 +71,21 @@ fun TimelineItemEventContentView( ) is TimelineItemImageContent -> TimelineItemImageView( content = content, + hideContent = hideContent, + onShowClick = onShowClick, onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) is TimelineItemStickerContent -> TimelineItemStickerView( content = content, + hideContent = hideContent, + onShowClick = onShowClick, modifier = modifier, ) is TimelineItemVideoContent -> TimelineItemVideoView( content = content, + hideContent = hideContent, + onShowClick = onShowClick, onContentLayoutChange = onContentLayoutChange, modifier = modifier ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 9f9222f458..e7cacede9e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou 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.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -58,6 +59,8 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemImageView( content: TimelineItemImageContent, + hideContent: Boolean, + onShowClick: () -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -76,23 +79,28 @@ fun TimelineItemImageView( modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f), aspectRatio = content.aspectRatio, ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier), - model = MediaRequestData( - source = content.preferredMediaSource, - kind = MediaRequestData.Kind.File( - body = content.filename ?: content.body, - mimeType = content.mimeType, + ProtectedView( + hideContent = hideContent, + onShowClick = onShowClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = MediaRequestData( + source = content.preferredMediaSource, + kind = MediaRequestData.Kind.File( + body = content.filename ?: content.body, + mimeType = content.mimeType, + ), ), - ), - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } } if (content.showCaption) { @@ -123,7 +131,23 @@ fun TimelineItemImageView( @PreviewsDayNight @Composable internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview { - TimelineItemImageView(content, {}) + TimelineItemImageView( + content = content, + hideContent = false, + onShowClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewHideContentPreview() = ElementPreview { + TimelineItemImageView( + content = aTimelineItemImageContent(), + hideContent = true, + onShowClick = {}, + onContentLayoutChange = {}, + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index f895eddc85..d41b985d86 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -7,44 +7,84 @@ package io.element.android.features.messages.impl.timeline.components.event -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.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.libraries.designsystem.components.blurhash.BlurHashAsyncImage +import io.element.android.features.messages.impl.timeline.protection.ProtectedView +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.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @Composable fun TimelineItemStickerView( content: TimelineItemStickerContent, + hideContent: Boolean, + onShowClick: () -> Unit, modifier: Modifier = Modifier, ) { - val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO - Box( - modifier = modifier - .heightIn(min = STICKER_SIZE_IN_DP.dp, max = STICKER_SIZE_IN_DP.dp) - .aspectRatio(aspectRatio, false), - contentAlignment = Alignment.TopStart, + val description = content.body.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) + Column( + modifier = modifier.semantics { contentDescription = description }, ) { - BlurHashAsyncImage( - model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), - blurHash = content.blurhash, - ) + TimelineItemAspectRatioBox( + modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), + aspectRatio = content.aspectRatio, + minHeight = STICKER_SIZE_IN_DP, + maxHeight = STICKER_SIZE_IN_DP, + ) { + ProtectedView( + hideContent = hideContent, + onShowClick = onShowClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxSize() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = MediaRequestData( + source = content.preferredMediaSource, + kind = MediaRequestData.Kind.File( + body = content.body, + mimeType = content.mimeType, + ), + ), + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } + } } } @PreviewsDayNight @Composable internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview { - TimelineItemStickerView(content) + TimelineItemStickerView( + content = content, + hideContent = false, + onShowClick = {}, + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 7324d3368b..9c28172c2e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou 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.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview @@ -64,6 +65,8 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemVideoView( content: TimelineItemVideoContent, + hideContent: Boolean, + onShowClick: () -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -83,33 +86,38 @@ fun TimelineItemVideoView( aspectRatio = content.aspectRatio, contentAlignment = Alignment.Center, ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier), - model = MediaRequestData( - source = content.thumbnailSource, - kind = MediaRequestData.Kind.File( - body = content.filename ?: content.body, - mimeType = content.mimeType - ) - ), - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) - - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, + ProtectedView( + hideContent = hideContent, + onShowClick = onShowClick, ) { - Image( - Icons.Default.PlayArrow, - contentDescription = stringResource(id = CommonStrings.a11y_play), - colorFilter = ColorFilter.tint(Color.White), + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = MediaRequestData( + source = content.thumbnailSource, + kind = MediaRequestData.Kind.File( + body = content.filename ?: content.body, + mimeType = content.mimeType + ) + ), + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, ) + + Box( + modifier = Modifier.roundedBackground(), + contentAlignment = Alignment.Center, + ) { + Image( + Icons.Default.PlayArrow, + contentDescription = stringResource(id = CommonStrings.a11y_play), + colorFilter = ColorFilter.tint(Color.White), + ) + } } } @@ -141,7 +149,23 @@ fun TimelineItemVideoView( @PreviewsDayNight @Composable internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview { - TimelineItemVideoView(content, {}) + TimelineItemVideoView( + content = content, + hideContent = false, + onShowClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewHideContentPreview() = ElementPreview { + TimelineItemVideoView( + content = aTimelineItemVideoContent(), + hideContent = true, + onShowClick = {}, + onContentLayoutChange = {}, + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt index 0d90ec9d09..268cda1829 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -16,22 +16,26 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider get() = sequenceOf( aTimelineItemImageContent(), - aTimelineItemImageContent().copy(aspectRatio = 1.0f), - aTimelineItemImageContent().copy(aspectRatio = 1.5f), + aTimelineItemImageContent(aspectRatio = 1.0f), + aTimelineItemImageContent(aspectRatio = 1.5f), + aTimelineItemImageContent(blurhash = null), ) } -fun aTimelineItemImageContent() = TimelineItemImageContent( +fun aTimelineItemImageContent( + aspectRatio: Float = 0.5f, + blurhash: String? = A_BLUR_HASH, +) = TimelineItemImageContent( body = "a body", formatted = null, filename = null, mediaSource = MediaSource(""), thumbnailSource = null, mimeType = MimeTypes.IMAGE_JPEG, - blurhash = A_BLUR_HASH, + blurhash = blurhash, width = null, height = 300, - aspectRatio = 0.5f, + aspectRatio = aspectRatio, formattedFileSize = "4MB", fileExtension = "jpg" ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt index adff977e32..bf231bd7fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt @@ -16,20 +16,24 @@ open class TimelineItemStickerContentProvider : PreviewParameterProvider get() = sequenceOf( aTimelineItemStickerContent(), - aTimelineItemStickerContent().copy(aspectRatio = 1.0f), - aTimelineItemStickerContent().copy(aspectRatio = 1.5f), + aTimelineItemStickerContent(aspectRatio = 1.0f), + aTimelineItemStickerContent(aspectRatio = 1.5f), + aTimelineItemStickerContent(blurhash = null), ) } -fun aTimelineItemStickerContent() = TimelineItemStickerContent( +fun aTimelineItemStickerContent( + aspectRatio: Float = 0.5f, + blurhash: String? = A_BLUR_HASH, +) = TimelineItemStickerContent( body = "a body", mediaSource = MediaSource(""), thumbnailSource = null, mimeType = MimeTypes.IMAGE_JPEG, - blurhash = A_BLUR_HASH, + blurhash = blurhash, width = null, height = 128, - aspectRatio = 0.5f, + aspectRatio = aspectRatio, formattedFileSize = "4MB", fileExtension = "jpg" ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index 9d494e95e9..510de5a100 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -17,18 +17,22 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider get() = sequenceOf( aTimelineItemVideoContent(), - aTimelineItemVideoContent().copy(aspectRatio = 1.0f), - aTimelineItemVideoContent().copy(aspectRatio = 1.5f), + aTimelineItemVideoContent(aspectRatio = 1.0f), + aTimelineItemVideoContent(aspectRatio = 1.5f), + aTimelineItemVideoContent(blurhash = null), ) } -fun aTimelineItemVideoContent() = TimelineItemVideoContent( +fun aTimelineItemVideoContent( + aspectRatio: Float = 0.5f, + blurhash: String? = A_BLUR_HASH, +) = TimelineItemVideoContent( body = "Video.mp4", formatted = null, filename = null, thumbnailSource = null, - blurHash = A_BLUR_HASH, - aspectRatio = 0.5f, + blurHash = blurhash, + aspectRatio = aspectRatio, duration = 100.milliseconds, videoSource = MediaSource(""), height = 300, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt new file mode 100644 index 0000000000..d7e8d94963 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.theme.components.Button + +@Composable +fun BoxScope.ProtectedView( + hideContent: Boolean, + onShowClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + if (hideContent) { + // TODO Update design, wording for video? + Button( + modifier = modifier.align(Alignment.Center), + text = "Show", + onClick = onShowClick, + ) + } else { + content() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt new file mode 100644 index 0000000000..824f3843ea --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +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 + +/** + * Return true if the event must be hidden by default when the setting to hide images and videos is enabled. + */ +fun TimelineItem.mustBeProtected(): Boolean { + return when (this) { + is TimelineItem.Event -> when (content) { + is TimelineItemImageContent, + is TimelineItemVideoContent, + is TimelineItemStickerContent -> true + is TimelineItemAudioContent, + is TimelineItemCallNotifyContent, + is TimelineItemEncryptedContent, + is TimelineItemFileContent, + TimelineItemLegacyCallInviteContent, + is TimelineItemLocationContent, + is TimelineItemPollContent, + TimelineItemRedactedContent, + is TimelineItemProfileChangeContent, + is TimelineItemRoomMembershipContent, + is TimelineItemStateEventContent, + is TimelineItemEmoteContent, + is TimelineItemNoticeContent, + is TimelineItemTextContent, + TimelineItemUnknownContent, + is TimelineItemVoiceContent -> false + } + is TimelineItem.Virtual -> false + is TimelineItem.GroupedEvents -> false + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt new file mode 100644 index 0000000000..78c878c3a4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface TimelineProtectionEvent { + data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt new file mode 100644 index 0000000000..46ac375aa4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +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 androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.collections.immutable.toImmutableSet +import javax.inject.Inject + +class TimelineProtectionPresenter @Inject constructor( + private val appPreferencesStore: AppPreferencesStore, +) : Presenter { + @Composable + override fun present(): TimelineProtectionState { + val hideContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false) + var allowedEvents by remember { mutableStateOf>(setOf()) } + val protectionState by remember(hideContent) { + derivedStateOf { + if (hideContent) { + ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) + } else { + ProtectionState.RenderAll + } + } + } + + fun handleEvent(event: TimelineProtectionEvent) { + when (event) { + is TimelineProtectionEvent.ShowContent -> { + allowedEvents = allowedEvents + setOfNotNull(event.eventId) + } + } + } + + return TimelineProtectionState( + protectionState = protectionState, + eventSink = { event -> handleEvent(event) } + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt new file mode 100644 index 0000000000..66ebb279f5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.ImmutableSet + +data class TimelineProtectionState( + val protectionState: ProtectionState, + val eventSink: (TimelineProtectionEvent) -> Unit, +) { + fun hideContent(eventId: EventId?) = when (protectionState) { + is ProtectionState.RenderAll -> false + is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds + } +} + +@Immutable +sealed interface ProtectionState { + data object RenderAll : ProtectionState + data class RenderOnly(val eventIds: ImmutableSet) : ProtectionState +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt new file mode 100644 index 0000000000..f0c2acbaa7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +fun aTimelineProtectionState( + protectionState: ProtectionState = ProtectionState.RenderAll, + eventSink: (TimelineProtectionEvent) -> Unit = {}, +) = TimelineProtectionState( + protectionState = protectionState, + eventSink = eventSink, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index 77602c4e12..6d8572a516 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -13,5 +13,6 @@ sealed interface DeveloperSettingsEvents { data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents data class SetSimplifiedSlidingSyncEnabled(val isEnabled: Boolean) : DeveloperSettingsEvents + data class SetHideImagesAndVideos(val value: Boolean) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index af7c05f592..a113a80a1f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -71,6 +71,9 @@ class DeveloperSettingsPresenter @Inject constructor( val isSimplifiedSlidingSyncEnabled by appPreferencesStore .isSimplifiedSlidingSyncEnabledFlow() .collectAsState(initial = false) + val hideImagesAndVideos by appPreferencesStore + .doesHideImagesAndVideosFlow() + .collectAsState(initial = false) LaunchedEffect(Unit) { FeatureFlags.entries @@ -114,6 +117,9 @@ class DeveloperSettingsPresenter @Inject constructor( appPreferencesStore.setSimplifiedSlidingSyncEnabled(event.isEnabled) logoutUseCase.logout(ignoreSdkError = true) } + is DeveloperSettingsEvents.SetHideImagesAndVideos -> coroutineScope.launch { + appPreferencesStore.setHideImagesAndVideos(event.value) + } } } @@ -128,6 +134,7 @@ class DeveloperSettingsPresenter @Inject constructor( validator = ::customElementCallUrlValidator, ), isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled, + hideImagesAndVideos = hideImagesAndVideos, eventSink = ::handleEvents ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index f4b599a504..e4c8641197 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -19,6 +19,7 @@ data class DeveloperSettingsState( val clearCacheAction: AsyncData, val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val isSimpleSlidingSyncEnabled: Boolean, + val hideImagesAndVideos: Boolean, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index 34918c6ea4..601ed2ee7a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -31,6 +31,7 @@ fun aDeveloperSettingsState( clearCacheAction: AsyncData = AsyncData.Uninitialized, customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), isSimplifiedSlidingSyncEnabled: Boolean = false, + hideImagesAndVideos: Boolean = false, eventSink: (DeveloperSettingsEvents) -> Unit = {}, ) = DeveloperSettingsState( features = aFeatureUiModelList(), @@ -39,6 +40,7 @@ fun aDeveloperSettingsState( clearCacheAction = clearCacheAction, customElementCallBaseUrlState = customElementCallBaseUrlState, isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled, + hideImagesAndVideos = hideImagesAndVideos, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index f2e427e3be..dc06036c43 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -40,9 +40,10 @@ fun DeveloperSettingsView( title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. + SettingsCategory(state) PreferenceCategory( title = "Feature flags", - showTopDivider = false, + showTopDivider = true, ) { FeatureListContent(state) } @@ -92,6 +93,22 @@ fun DeveloperSettingsView( } } +@Composable +private fun SettingsCategory( + state: DeveloperSettingsState, +) { + PreferenceCategory(title = "Preferences", showTopDivider = false) { + PreferenceSwitch( + title = "Hide image & video previews", + subtitle = "When toggled image & video will not render in the timeline by default.", + isChecked = state.hideImagesAndVideos, + onCheckedChange = { + state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(it)) + } + ) + } +} + @Composable private fun ElementCallCategory( state: DeveloperSettingsState, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 0b1ce2cfdb..128f4aa705 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -50,6 +50,7 @@ class DeveloperSettingsPresenterTest { assertThat(initialState.customElementCallBaseUrlState).isNotNull() assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull() assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse() + assertThat(initialState.hideImagesAndVideos).isFalse() val loadedState = awaitItem() assertThat(loadedState.rageshakeState.isEnabled).isFalse() assertThat(loadedState.rageshakeState.isSupported).isTrue() @@ -179,6 +180,24 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - toggling hide image and video`() = runTest { + val preferences = InMemoryAppPreferencesStore() + val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.hideImagesAndVideos).isFalse() + initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true)) + assertThat(awaitItem().hideImagesAndVideos).isTrue() + assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue() + initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false)) + assertThat(awaitItem().hideImagesAndVideos).isFalse() + assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse() + } + } + private fun createDeveloperSettingsPresenter( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index 82e8df6993..f6319007d4 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -45,6 +45,7 @@ class DeveloperSettingsViewTest { } } + @Config(qualifiers = "h1500dp") @Test fun `clicking on element call url open the dialogs and submit emits the expected event`() { val eventsRecorder = EventsRecorder() @@ -113,6 +114,18 @@ class DeveloperSettingsViewTest { rule.onNodeWithText("Enable Simplified Sliding Sync").performClick() eventsRecorder.assertSingle(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true)) } + + @Test + fun `clicking on the hide images and videos switch emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Hide image & video previews").performClick() + eventsRecorder.assertSingle(DeveloperSettingsEvents.SetHideImagesAndVideos(true)) + } } private fun AndroidComposeTestRule.setDeveloperSettingsView( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index bea0911544..0225a44750 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -145,7 +145,7 @@ data class AttachmentThumbnailInfo( @Composable internal fun AttachmentThumbnailPreview(@PreviewParameter(AttachmentThumbnailInfoProvider::class) data: AttachmentThumbnailInfo) = ElementPreview { AttachmentThumbnail( - data, + info = data, modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(4.dp)) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt index b7ba762ce6..1da14d3839 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt @@ -57,11 +57,11 @@ internal sealed interface InReplyToMetadata { * Metadata can be either a thumbnail with a text OR just a text. */ @Composable -internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) { +internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) { is MessageContent -> when (val type = eventContent.type) { is ImageMessageType -> InReplyToMetadata.Thumbnail( AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource ?: type.source, + thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.Image, blurHash = type.info?.blurhash, @@ -69,7 +69,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event ) is VideoMessageType -> InReplyToMetadata.Thumbnail( AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource, + thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.Video, blurHash = type.info?.blurhash, @@ -77,7 +77,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event ) is FileMessageType -> InReplyToMetadata.Thumbnail( AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource, + thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.File, ) @@ -104,7 +104,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event } is StickerContent -> InReplyToMetadata.Thumbnail( AttachmentThumbnailInfo( - thumbnailSource = eventContent.source, + thumbnailSource = eventContent.source.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.Image, blurHash = eventContent.info.blurhash, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 6fe11ec254..70a30c809a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -48,6 +48,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun InReplyToView( inReplyTo: InReplyToDetails, + hideImage: Boolean, modifier: Modifier = Modifier, ) { when (inReplyTo) { @@ -55,7 +56,7 @@ fun InReplyToView( ReplyToReadyContent( senderId = inReplyTo.senderId, senderProfile = inReplyTo.senderProfile, - metadata = inReplyTo.metadata(), + metadata = inReplyTo.metadata(hideImage), modifier = modifier ) } @@ -191,5 +192,8 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) { @PreviewsDayNight @Composable internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { - InReplyToView(inReplyTo) + InReplyToView( + inReplyTo = inReplyTo, + hideImage = false, + ) } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 26e7df9631..9e4318b3be 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -61,7 +61,7 @@ class InReplyToMetadataKtTest { @Test fun `any message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - anInReplyToDetailsReady(eventContent = aMessageContent()).metadata() + anInReplyToDetailsReady(eventContent = aMessageContent()).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent")) @@ -82,7 +82,7 @@ class InReplyToMetadataKtTest { info = anImageInfo(), ) ) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -99,6 +99,36 @@ class InReplyToMetadataKtTest { } } + @Test + fun `an image message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = ImageMessageType( + body = "body", + formatted = null, + filename = null, + source = aMediaSource(), + info = anImageInfo(), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + @Test fun `a sticker message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { @@ -108,7 +138,7 @@ class InReplyToMetadataKtTest { info = anImageInfo(), source = aMediaSource(url = "url") ) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -125,6 +155,32 @@ class InReplyToMetadataKtTest { } } + @Test + fun `a sticker message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = StickerContent( + body = "body", + info = anImageInfo(), + source = aMediaSource(url = "url") + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + @Test fun `a video message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { @@ -138,7 +194,7 @@ class InReplyToMetadataKtTest { info = aVideoInfo(), ) ) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -155,6 +211,36 @@ class InReplyToMetadataKtTest { } } + @Test + fun `a video message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = VideoMessageType( + body = "body", + formatted = null, + filename = null, + source = aMediaSource(), + info = aVideoInfo(), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Video, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + @Test fun `a file message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { @@ -171,7 +257,7 @@ class InReplyToMetadataKtTest { ), ) ) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -188,6 +274,39 @@ class InReplyToMetadataKtTest { } } + @Test + fun `a file message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = FileMessageType( + body = "body", + source = aMediaSource(), + info = FileInfo( + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + ), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.File, + blurHash = null, + ) + ) + ) + } + } + } + @Test fun `a audio message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { @@ -203,7 +322,7 @@ class InReplyToMetadataKtTest { ), ) ) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -231,7 +350,7 @@ class InReplyToMetadataKtTest { description = null, ) ) - ).metadata() + ).metadata(hideImage = false) } }.test { awaitItem().let { @@ -262,7 +381,7 @@ class InReplyToMetadataKtTest { details = null, ) ) - ).metadata() + ).metadata(hideImage = false) } }.test { awaitItem().let { @@ -285,7 +404,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = aPollContent() - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo( @@ -307,7 +426,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = RedactedContent - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo(InReplyToMetadata.Redacted) @@ -320,7 +439,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt) @@ -333,7 +452,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = FailedToParseMessageLikeContent("", "") - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -346,7 +465,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = FailedToParseStateContent("", "", "") - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -359,7 +478,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = ProfileChangeContent("", "", "", "") - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -372,7 +491,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = RoomMembershipContent(A_USER_ID, null, null) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -385,7 +504,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = StateContent("", OtherState.RoomJoinRules) - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -398,7 +517,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = UnknownContent - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() @@ -411,7 +530,7 @@ class InReplyToMetadataKtTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( eventContent = null - ).metadata() + ).metadata(hideImage = false) }.test { awaitItem().let { assertThat(it).isNull() diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index f05b56614a..ecdcca780b 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -22,5 +22,8 @@ interface AppPreferencesStore { suspend fun setSimplifiedSlidingSyncEnabled(enabled: Boolean) fun isSimplifiedSlidingSyncEnabledFlow(): Flow + suspend fun setHideImagesAndVideos(value: Boolean) + fun doesHideImagesAndVideosFlow(): Flow + suspend fun reset() } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index ab985e798d..d9cbc6cc03 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -30,6 +30,7 @@ private val developerModeKey = booleanPreferencesKey("developerMode") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val themeKey = stringPreferencesKey("theme") private val simplifiedSlidingSyncKey = booleanPreferencesKey("useSimplifiedSlidingSync") +private val hideImagesAndVideosKey = booleanPreferencesKey("hideImagesAndVideos") @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore @Inject constructor( @@ -91,6 +92,18 @@ class DefaultAppPreferencesStore @Inject constructor( } } + override suspend fun setHideImagesAndVideos(value: Boolean) { + store.edit { prefs -> + prefs[hideImagesAndVideosKey] = value + } + } + + override fun doesHideImagesAndVideosFlow(): Flow { + return store.data.map { prefs -> + prefs[hideImagesAndVideosKey] ?: false + } + } + override suspend fun reset() { store.edit { it.clear() } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index c4591c9355..36ad0f9363 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -13,11 +13,13 @@ import kotlinx.coroutines.flow.MutableStateFlow class InMemoryAppPreferencesStore( isDeveloperModeEnabled: Boolean = false, + hideImagesAndVideos: Boolean = false, customElementCallBaseUrl: String? = null, theme: String? = null, simplifiedSlidingSyncEnabled: Boolean = false ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) + private val hideImagesAndVideos = MutableStateFlow(hideImagesAndVideos) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) private val simplifiedSlidingSyncEnabled = MutableStateFlow(simplifiedSlidingSyncEnabled) @@ -54,6 +56,14 @@ class InMemoryAppPreferencesStore( return simplifiedSlidingSyncEnabled } + override suspend fun setHideImagesAndVideos(value: Boolean) { + hideImagesAndVideos.value = value + } + + override fun doesHideImagesAndVideosFlow(): Flow { + return hideImagesAndVideos + } + override suspend fun reset() { // No op } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt index a8d22330d3..f9988adfba 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -48,6 +48,7 @@ internal fun ComposerModeView( ReplyToModeView( modifier = Modifier.padding(8.dp), replyToDetails = composerMode.replyToDetails, + hideImage = composerMode.hideImage, onResetComposerMode = onResetComposerMode, ) } @@ -103,6 +104,7 @@ private fun EditingModeView( @Composable private fun ReplyToModeView( replyToDetails: InReplyToDetails, + hideImage: Boolean, onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { @@ -112,7 +114,11 @@ private fun ReplyToModeView( .background(MaterialTheme.colorScheme.surface) .padding(4.dp) ) { - InReplyToView(inReplyTo = replyToDetails, modifier = Modifier.weight(1f)) + InReplyToView( + inReplyTo = replyToDetails, + hideImage = hideImage, + modifier = Modifier.weight(1f), + ) Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close), diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index a71461e338..1f2e041ac5 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -118,8 +118,8 @@ fun TextComposer( } val layoutModifier = modifier - .fillMaxSize() - .height(IntrinsicSize.Min) + .fillMaxSize() + .height(IntrinsicSize.Min) val composerOptionsButton: @Composable () -> Unit = remember { @Composable { @@ -316,8 +316,8 @@ private fun StandardLayout( if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { Box( modifier = Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) - .size(48.dp), + .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { voiceDeleteButton() @@ -327,8 +327,8 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { voiceRecording() } @@ -341,16 +341,16 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { textInput() } } Box( - Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) - .size(48.dp), + Modifier + .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { endButton() @@ -372,8 +372,8 @@ private fun TextFormattingLayout( ) { Box( modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp) + .weight(1f) + .padding(horizontal = 12.dp) ) { textInput() } @@ -417,21 +417,24 @@ private fun TextInputBox( Column( modifier = Modifier - .clip(roundedCorners) - .border(0.5.dp, borderColor, roundedCorners) - .background(color = bgColor) - .requiredHeightIn(min = 42.dp) - .fillMaxSize(), + .clip(roundedCorners) + .border(0.5.dp, borderColor, roundedCorners) + .background(color = bgColor) + .requiredHeightIn(min = 42.dp) + .fillMaxSize(), ) { if (composerMode is MessageComposerMode.Special) { - ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) + ComposerModeView( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + ) } val defaultTypography = ElementTheme.typography.fontBodyLgRegular Box( modifier = Modifier - .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) - // Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail - .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier), + .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) + // Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail + .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier), contentAlignment = Alignment.CenterStart, ) { // Placeholder @@ -477,8 +480,8 @@ private fun TextInput( // This prevents it gaining focus and mutating the state. registerStateUpdates = !subcomposing, modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), resolveMentionDisplay = resolveMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay, @@ -603,6 +606,7 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( replyToDetails = inReplyToDetails, + hideImage = false, ), enableVoiceMessages = true, ) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt index 69ff2eeb48..fafa65e64f 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt @@ -27,7 +27,8 @@ sealed interface MessageComposerMode { ) : Special data class Reply( - val replyToDetails: InReplyToDetails + val replyToDetails: InReplyToDetails, + val hideImage: Boolean, ) : Special { val eventId: EventId = replyToDetails.eventId() }