Merge pull request #3592 from element-hq/feature/bma/hideImages

Add developer setting to hide images in the timeline
This commit is contained in:
Benoit Marty 2024-10-04 17:57:37 +02:00 committed by GitHub
commit 872bab938a
86 changed files with 1103 additions and 193 deletions

View file

@ -46,6 +46,7 @@ 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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.model.event.TimelineItemTextBasedContent
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.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
@ -90,6 +91,7 @@ class MessagesPresenter @AssistedInject constructor(
private val composerPresenter: MessageComposerPresenter, private val composerPresenter: MessageComposerPresenter,
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter, private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
timelinePresenterFactory: TimelinePresenter.Factory, timelinePresenterFactory: TimelinePresenter.Factory,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val actionListPresenterFactory: ActionListPresenter.Factory, private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
@ -123,6 +125,7 @@ class MessagesPresenter @AssistedInject constructor(
val composerState = composerPresenter.present() val composerState = composerPresenter.present()
val voiceMessageComposerState = voiceMessageComposerPresenter.present() val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present() val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
@ -182,6 +185,7 @@ class MessagesPresenter @AssistedInject constructor(
composerState = composerState, composerState = composerState,
enableTextFormatting = composerState.showTextFormatting, enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState, timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
) )
} }
is MessagesEvents.ToggleReaction -> { is MessagesEvents.ToggleReaction -> {
@ -213,6 +217,7 @@ class MessagesPresenter @AssistedInject constructor(
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState, timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,
@ -262,6 +267,7 @@ class MessagesPresenter @AssistedInject constructor(
action: TimelineItemAction, action: TimelineItemAction,
targetEvent: TimelineItem.Event, targetEvent: TimelineItem.Event,
composerState: MessageComposerState, composerState: MessageComposerState,
timelineProtectionState: TimelineProtectionState,
enableTextFormatting: Boolean, enableTextFormatting: Boolean,
timelineState: TimelineState, timelineState: TimelineState,
) = launch { ) = launch {
@ -271,7 +277,7 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState) TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent) TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
@ -385,11 +391,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 if (targetEvent.eventId == null) return
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser) val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
val composerMode = MessageComposerMode.Reply(replyToDetails = replyToDetails) val composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId),
)
composerState.eventSink( composerState.eventSink(
MessageComposerEvents.SetMode(composerMode) MessageComposerEvents.SetMode(composerMode)
) )

View file

@ -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.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState 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.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.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -32,6 +33,7 @@ data class MessagesState(
val composerState: MessageComposerState, val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,

View file

@ -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.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState 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 // Render a focused event for an event with sender information displayed
focusedEventIndex = 2, focusedEventIndex = 2,
), ),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(), customReactionState: CustomReactionState = aCustomReactionState(),
@ -121,6 +124,7 @@ fun aMessagesState(
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
composerState = composerState, composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
timelineState = timelineState, timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState, actionListState = actionListState,

View file

@ -379,6 +379,7 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior() val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
timelineProtectionState = state.timelineProtectionState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,

View file

@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
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.TypingNotificationPresenter import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -30,4 +32,7 @@ interface MessagesModule {
@Binds @Binds
fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter<TypingNotificationState> fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter<TypingNotificationState>
@Binds
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
} }

View file

@ -585,10 +585,18 @@ class MessageComposerPresenter @Inject constructor(
content = htmlText ?: markdownText content = htmlText ?: markdownText
) )
is ComposerDraftType.Reply -> { 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 { timelineController.invokeOnCurrentTimeline {
val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser) val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser)
run { messageComposerContext.composerMode = MessageComposerMode.Reply(replyToDetails) } messageComposerContext.composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
// I guess it's fine to always render the image when restoring a draft
hideImage = false
)
} }
} }
} }

View file

@ -30,6 +30,7 @@ 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.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig 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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -60,6 +61,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
timelineItemsFactoryCreator: TimelineItemsFactory.Creator, timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider, private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory, actionListPresenterFactory: ActionListPresenter.Factory,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
@ -97,14 +99,13 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
) )
) )
} }
val timelineProtectionState = timelineProtectionPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
var pinnedMessageItems by remember { var pinnedMessageItems by remember {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized) mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
} }
PinnedMessagesListEffect( PinnedMessagesListEffect(
onItemsChange = { newItems -> onItemsChange = { newItems ->
pinnedMessageItems = newItems pinnedMessageItems = newItems
@ -119,6 +120,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
return pinnedMessagesListState( return pinnedMessagesListState(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems, timelineItems = pinnedMessageItems,
eventSink = ::handleEvents eventSink = ::handleEvents
@ -214,6 +216,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
@Composable @Composable
private fun pinnedMessagesListState( private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
userEventPermissions: UserEventPermissions, userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>, timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit eventSink: (PinnedMessagesListEvents) -> Unit
@ -228,6 +231,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
PinnedMessagesListState.Filled( PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data, timelineItems = timelineItems.data,
actionListState = actionListState, actionListState = actionListState,

View file

@ -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.actionlist.ActionListState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo 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.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.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -26,6 +27,7 @@ sealed interface PinnedMessagesListState {
data object Empty : PinnedMessagesListState data object Empty : PinnedMessagesListState
data class Filled( data class Filled(
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
val timelineProtectionState: TimelineProtectionState,
val userEventPermissions: UserEventPermissions, val userEventPermissions: UserEventPermissions,
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState, val actionListState: ActionListState,

View file

@ -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.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent 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.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.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -83,12 +85,14 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState( fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
timelineItems: List<TimelineItem> = emptyList(), timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
eventSink: (PinnedMessagesListEvents) -> Unit = {} eventSink: (PinnedMessagesListEvents) -> Unit = {}
) = PinnedMessagesListState.Filled( ) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
timelineItems = timelineItems.toImmutableList(), timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState, actionListState = actionListState,
userEventPermissions = aUserEventPermissions, userEventPermissions = aUserEventPermissions,

View file

@ -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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@ -77,8 +79,8 @@ fun PinnedMessagesListView(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onErrorDismiss = onBackClick, onErrorDismiss = onBackClick,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) )
} }
) )
@ -208,6 +210,7 @@ private fun PinnedMessagesListLoaded(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false, renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
focusedEventId = null, focusedEventId = null,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
@ -225,6 +228,7 @@ private fun PinnedMessagesListLoaded(
eventContentView = { event, contentModifier, onContentLayoutChange -> eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper( TimelineItemEventContentViewWrapper(
event = event, event = event,
timelineProtectionState = state.timelineProtectionState,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
@ -238,6 +242,7 @@ private fun PinnedMessagesListLoaded(
@Composable @Composable
private fun TimelineItemEventContentViewWrapper( private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -251,6 +256,8 @@ private fun TimelineItemEventContentViewWrapper(
} else { } else {
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = { }, eventSink = { },
modifier = modifier, modifier = modifier,

View file

@ -57,7 +57,7 @@ class TimelineController @Inject constructor(
return detachedTimeline.map { !it.isPresent } return detachedTimeline.map { !it.isPresent }
} }
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) { suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) {
currentTimelineFlow.value.run { currentTimelineFlow.value.run {
block(this) block(this)
} }

View file

@ -243,8 +243,8 @@ class TimelinePresenter @AssistedInject constructor(
} }
} }
return TimelineState( return TimelineState(
timelineRoomInfo = timelineRoomInfo,
timelineItems = timelineItems, timelineItems = timelineItems,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value, newEventState = newEventState.value,
isLive = isLive, isLive = isLive,

View file

@ -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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent 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.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.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -65,13 +67,13 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun TimelineView( fun TimelineView(
state: TimelineState, state: TimelineState,
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit,
@ -114,10 +116,6 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId)) state.eventSink(TimelineEvents.FocusOnEvent(eventId))
} }
fun onShieldClick(shield: MessageShield) {
state.eventSink(TimelineEvents.ShowShieldDialog(shield))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
@ -137,6 +135,7 @@ fun TimelineView(
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts, renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
@ -320,6 +319,7 @@ internal fun TimelineViewPreview(
), ),
focusedEventIndex = 0, focusedEventIndex = 0,
), ),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},

View file

@ -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.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -35,6 +36,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
timelineItems = items.toImmutableList(), timelineItems = items.toImmutableList(),
messageShield = messageShield, messageShield = messageShield,
), ),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},

View file

@ -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.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo 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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
// For previews // For previews
@Composable @Composable
@ -20,10 +22,12 @@ internal fun ATimelineItemEventRow(
renderReadReceipts: Boolean = false, renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false, isLastOutgoingMessage: Boolean = false,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
) = TimelineItemEventRow( ) = TimelineItemEventRow(
event = event, event = event,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
onClick = {}, onClick = {},

View file

@ -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.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.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.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -108,6 +110,7 @@ private val BUBBLE_INCOMING_OFFSET = 16.dp
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
@ -126,6 +129,8 @@ fun TimelineItemEventRow(
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
@ -164,6 +169,7 @@ fun TimelineItemEventRow(
} }
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -197,6 +203,7 @@ fun TimelineItemEventRow(
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -252,6 +259,7 @@ private fun SwipeSensitivity(
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
isHighlighted: Boolean, isHighlighted: Boolean,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
@ -330,6 +338,7 @@ private fun TimelineItemEventRowContent(
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
timelineProtectionState = timelineProtectionState,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
eventSink = eventSink, eventSink = eventSink,
@ -411,6 +420,7 @@ private fun MessageSenderInformation(
@Composable @Composable
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@ -560,7 +570,11 @@ private fun MessageEventBubbleContent(
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
.clickable(onClick = inReplyToClick) .clickable(onClick = inReplyToClick)
InReplyToView(inReplyTo, modifier = inReplyToModifier) InReplyToView(
inReplyTo = inReplyTo,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
modifier = inReplyToModifier,
)
} }
if (inReplyToDetails != null) { if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance. // Use SubComposeLayout only if necessary as it can have consequences on the performance.

View file

@ -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.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -34,6 +37,7 @@ import io.element.android.libraries.matrix.api.core.UserId
fun TimelineItemGroupedEventsRow( fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
focusedEventId: EventId?, focusedEventId: EventId?,
@ -52,6 +56,8 @@ fun TimelineItemGroupedEventsRow(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
@ -70,6 +76,7 @@ fun TimelineItemGroupedEventsRow(
onExpandGroupClick = ::onExpandGroupClick, onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
@ -94,6 +101,7 @@ private fun TimelineItemGroupedEventsRowContent(
onExpandGroupClick: () -> Unit, onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
@ -112,6 +120,8 @@ private fun TimelineItemGroupedEventsRowContent(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
@ -136,6 +146,7 @@ private fun TimelineItemGroupedEventsRowContent(
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
@ -178,6 +189,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
onExpandGroupClick = {}, onExpandGroupClick = {},
timelineItem = events, timelineItem = events,
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = events.events.first().eventId, focusedEventId = events.events.first().eventId,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
@ -202,6 +214,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
onExpandGroupClick = {}, onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true), timelineItem = aGroupedEvents(withReadReceipts = true),
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null, focusedEventId = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,

View file

@ -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.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent 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.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.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -37,6 +40,7 @@ internal fun TimelineItemRow(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
@ -55,6 +59,8 @@ internal fun TimelineItemRow(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
@ -109,9 +115,14 @@ internal fun TimelineItemRow(
event = timelineItem, event = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId), isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) }, onClick = if (timelineProtectionState.hideMediaContent(timelineItem.eventId) && timelineItem.mustBeProtected()) {
{}
} else {
{ onClick(timelineItem) }
},
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
@ -133,6 +144,7 @@ internal fun TimelineItemRow(
TimelineItemGroupedEventsRow( TimelineItemGroupedEventsRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,

View file

@ -71,6 +71,8 @@ fun TimelineItemStateEventRow(
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
onLinkClick = {}, onLinkClick = {},
hideMediaContent = false,
onShowClick = {},
eventSink = eventSink, eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )

View file

@ -25,12 +25,14 @@ fun TimelineItemAspectRatioBox(
aspectRatio: Float?, aspectRatio: Float?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart, contentAlignment: Alignment = Alignment.TopStart,
minHeight: Int = MIN_HEIGHT_IN_DP,
maxHeight: Int = MAX_HEIGHT_IN_DP,
content: @Composable (BoxScope.() -> Unit), content: @Composable (BoxScope.() -> Unit),
) { ) {
val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO
Box( Box(
modifier = modifier 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), .aspectRatio(safeAspectRatio, false),
contentAlignment = contentAlignment, contentAlignment = contentAlignment,
content = content content = content

View file

@ -35,6 +35,8 @@ import io.element.android.libraries.architecture.Presenter
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onLinkClick: (url: String) -> Unit, onLinkClick: (url: String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -69,15 +71,21 @@ fun TimelineItemEventContentView(
) )
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
content = content, content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier, modifier = modifier,
) )
is TimelineItemStickerContent -> TimelineItemStickerView( is TimelineItemStickerContent -> TimelineItemStickerView(
content = content, content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
modifier = modifier, modifier = modifier,
) )
is TimelineItemVideoContent -> TimelineItemVideoView( is TimelineItemVideoContent -> TimelineItemVideoView(
content = content, content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier modifier = modifier
) )

View file

@ -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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider 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.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.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -58,6 +59,8 @@ import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -76,23 +79,28 @@ fun TimelineItemImageView(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f), modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
) { ) {
var isLoaded by remember { mutableStateOf(false) } ProtectedView(
AsyncImage( hideContent = hideMediaContent,
modifier = Modifier onShowClick = onShowClick,
.fillMaxWidth() ) {
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), var isLoaded by remember { mutableStateOf(false) }
model = MediaRequestData( AsyncImage(
source = content.preferredMediaSource, modifier = Modifier
kind = MediaRequestData.Kind.File( .fillMaxWidth()
body = content.filename ?: content.body, .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
mimeType = content.mimeType, model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
body = content.filename ?: content.body,
mimeType = content.mimeType,
),
), ),
), contentScale = ContentScale.Fit,
contentScale = ContentScale.Fit, alignment = Alignment.Center,
alignment = Alignment.Center, contentDescription = description,
contentDescription = description, onState = { isLoaded = it is AsyncImagePainter.State.Success },
onState = { isLoaded = it is AsyncImagePainter.State.Success }, )
) }
} }
if (content.showCaption) { if (content.showCaption) {
@ -123,7 +131,23 @@ fun TimelineItemImageView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview { internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
TimelineItemImageView(content, {}) TimelineItemImageView(
content = content,
hideMediaContent = false,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowClick = {},
onContentLayoutChange = {},
)
} }
@PreviewsDayNight @PreviewsDayNight

View file

@ -7,44 +7,84 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.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.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData 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 private const val STICKER_SIZE_IN_DP = 128
@Composable @Composable
fun TimelineItemStickerView( fun TimelineItemStickerView(
content: TimelineItemStickerContent, content: TimelineItemStickerContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO val description = content.body.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
Box( Column(
modifier = modifier modifier = modifier.semantics { contentDescription = description },
.heightIn(min = STICKER_SIZE_IN_DP.dp, max = STICKER_SIZE_IN_DP.dp)
.aspectRatio(aspectRatio, false),
contentAlignment = Alignment.TopStart,
) { ) {
BlurHashAsyncImage( TimelineItemAspectRatioBox(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
blurHash = content.blurhash, aspectRatio = content.aspectRatio,
) minHeight = STICKER_SIZE_IN_DP,
maxHeight = STICKER_SIZE_IN_DP,
) {
ProtectedView(
hideContent = hideMediaContent,
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 @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview { internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview {
TimelineItemStickerView(content) TimelineItemStickerView(
content = content,
hideMediaContent = false,
onShowClick = {},
)
} }

View file

@ -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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider 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.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.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -64,6 +65,8 @@ import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
fun TimelineItemVideoView( fun TimelineItemVideoView(
content: TimelineItemVideoContent, content: TimelineItemVideoContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -83,33 +86,38 @@ fun TimelineItemVideoView(
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
var isLoaded by remember { mutableStateOf(false) } ProtectedView(
AsyncImage( hideContent = hideMediaContent,
modifier = Modifier onShowClick = onShowClick,
.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( var isLoaded by remember { mutableStateOf(false) }
Icons.Default.PlayArrow, AsyncImage(
contentDescription = stringResource(id = CommonStrings.a11y_play), modifier = Modifier
colorFilter = ColorFilter.tint(Color.White), .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 @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview { internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
TimelineItemVideoView(content, {}) TimelineItemVideoView(
content = content,
hideMediaContent = false,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
TimelineItemVideoView(
content = aTimelineItemVideoContent(),
hideMediaContent = true,
onShowClick = {},
onContentLayoutChange = {},
)
} }
@PreviewsDayNight @PreviewsDayNight

View file

@ -16,22 +16,26 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
override val values: Sequence<TimelineItemImageContent> override val values: Sequence<TimelineItemImageContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemImageContent(), aTimelineItemImageContent(),
aTimelineItemImageContent().copy(aspectRatio = 1.0f), aTimelineItemImageContent(aspectRatio = 1.0f),
aTimelineItemImageContent().copy(aspectRatio = 1.5f), 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", body = "a body",
formatted = null, formatted = null,
filename = null, filename = null,
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG, mimeType = MimeTypes.IMAGE_JPEG,
blurhash = A_BLUR_HASH, blurhash = blurhash,
width = null, width = null,
height = 300, height = 300,
aspectRatio = 0.5f, aspectRatio = aspectRatio,
formattedFileSize = "4MB", formattedFileSize = "4MB",
fileExtension = "jpg" fileExtension = "jpg"
) )

View file

@ -16,20 +16,24 @@ open class TimelineItemStickerContentProvider : PreviewParameterProvider<Timelin
override val values: Sequence<TimelineItemStickerContent> override val values: Sequence<TimelineItemStickerContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemStickerContent(), aTimelineItemStickerContent(),
aTimelineItemStickerContent().copy(aspectRatio = 1.0f), aTimelineItemStickerContent(aspectRatio = 1.0f),
aTimelineItemStickerContent().copy(aspectRatio = 1.5f), 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", body = "a body",
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG, mimeType = MimeTypes.IMAGE_JPEG,
blurhash = A_BLUR_HASH, blurhash = blurhash,
width = null, width = null,
height = 128, height = 128,
aspectRatio = 0.5f, aspectRatio = aspectRatio,
formattedFileSize = "4MB", formattedFileSize = "4MB",
fileExtension = "jpg" fileExtension = "jpg"
) )

View file

@ -17,18 +17,22 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
override val values: Sequence<TimelineItemVideoContent> override val values: Sequence<TimelineItemVideoContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemVideoContent(), aTimelineItemVideoContent(),
aTimelineItemVideoContent().copy(aspectRatio = 1.0f), aTimelineItemVideoContent(aspectRatio = 1.0f),
aTimelineItemVideoContent().copy(aspectRatio = 1.5f), 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", body = "Video.mp4",
formatted = null, formatted = null,
filename = null, filename = null,
thumbnailSource = null, thumbnailSource = null,
blurHash = A_BLUR_HASH, blurHash = blurhash,
aspectRatio = 0.5f, aspectRatio = aspectRatio,
duration = 100.milliseconds, duration = 100.milliseconds,
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300, height = 300,

View file

@ -0,0 +1,93 @@
/*
* 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.ui.strings.CommonStrings
@SuppressWarnings("ModifierClickableOrder")
@Composable
fun ProtectedView(
hideContent: Boolean,
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
if (hideContent) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0x99000000)),
contentAlignment = Alignment.Center,
) {
ElementTheme(darkTheme = false) {
// Not using a button to be able to have correct size
Text(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.clickable(
onClick = onShowClick,
role = Role.Button,
)
.padding(4.dp)
.border(
width = 1.dp,
color = ElementTheme.colors.borderInteractiveSecondary,
shape = RoundedCornerShape(percent = 50),
)
.padding(
horizontal = 16.dp,
vertical = 4.dp,
),
text = stringResource(CommonStrings.action_show),
color = ElementTheme.colors.textOnSolidPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
)
}
}
} else {
content()
}
}
@PreviewsDayNight
@Composable
internal fun ProtectedViewPreview() = ElementPreview {
Box(
modifier = Modifier
.size(160.dp)
.blurHashBackground(A_BLUR_HASH)
) {
ProtectedView(
hideContent = true,
onShowClick = {},
content = {},
)
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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<TimelineProtectionState> {
@Composable
override fun present(): TimelineProtectionState {
val hideMediaContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false)
var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) }
val protectionState by remember(hideMediaContent) {
derivedStateOf {
if (hideMediaContent) {
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) }
)
}
}

View file

@ -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 hideMediaContent(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<EventId>) : ProtectionState
}

View file

@ -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,
)

View file

@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent 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.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
@ -1059,12 +1060,12 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = voiceMessageComposerPresenter, voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenterFactory = timelinePresenterFactory, timelinePresenterFactory = timelinePresenterFactory,
timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenterFactory = FakeActionListPresenter.Factory, actionListPresenterFactory = FakeActionListPresenter.Factory,
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,

View file

@ -1521,4 +1521,7 @@ fun anEditMode(
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, transactionId, message) ) = MessageComposerMode.Edit(eventId, transactionId, message)
fun aReplyMode() = MessageComposerMode.Reply(replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID)) fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
hideImage = false,
)

View file

@ -14,6 +14,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -309,6 +310,7 @@ class PinnedMessagesListPresenterTest {
room = room, room = room,
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
timelineProvider = timelineProvider, timelineProvider = timelineProvider,
timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory, actionListPresenterFactory = FakeActionListPresenter.Factory,
analyticsService = analyticsService, analyticsService = analyticsService,

View file

@ -17,6 +17,8 @@ import io.element.android.features.messages.impl.timeline.components.aCriticalSh
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
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.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
@ -137,6 +139,7 @@ class TimelineViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
state: TimelineState, state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
@ -152,6 +155,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
setSafeContent { setSafeContent {
TimelineView( TimelineView(
state = state, state = state,
timelineProtectionState = timelineProtectionState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,

View file

@ -0,0 +1,69 @@
/*
* 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.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProtectedViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `when hideContent is false, the content is rendered`() {
rule.setProtectedView(
hideContent = false,
content = {
Text("Hello")
}
)
rule.onNodeWithText("Hello").assertExists()
}
@Test
fun `when hideContent is true, the content is not rendered, and user can reveal it`() {
ensureCalledOnce {
rule.setProtectedView(
hideContent = true,
onShowClick = it,
content = {
Text("Hello")
}
)
rule.onNodeWithText("Hello").assertDoesNotExist()
rule.clickOn(CommonStrings.action_show)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setProtectedView(
hideContent: Boolean = false,
onShowClick: () -> Unit = { lambdaError() },
content: @Composable () -> Unit = {},
) {
setContent {
ProtectedView(
hideContent = hideContent,
onShowClick = onShowClick,
content = content
)
}
}

View file

@ -0,0 +1,55 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class TimelineProtectionPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll)
}
}
@Test
fun `present - protected`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(hideImagesAndVideos = true)
val presenter = createPresenter(appPreferencesStore)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
// ShowContent with null should have no effect.
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null))
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
}
private fun createPresenter(
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
) = TimelineProtectionPresenter(
appPreferencesStore = appPreferencesStore,
)
}

View file

@ -0,0 +1,44 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
class TimelineProtectionStateTest {
@Test
fun `when protectionState is RenderAll, hideMediaContent always return null`() {
val sut = aTimelineProtectionState(
protectionState = ProtectionState.RenderAll
)
assertThat(sut.hideMediaContent(null)).isFalse()
assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse()
}
@Test
fun `when protectionState is RenderOnly with empty set, hideMediaContent always return true`() {
val sut = aTimelineProtectionState(
protectionState = ProtectionState.RenderOnly(persistentSetOf())
)
assertThat(sut.hideMediaContent(null)).isTrue()
assertThat(sut.hideMediaContent(AN_EVENT_ID)).isTrue()
}
@Test
fun `when protectionState is RenderOnly with an Event, hideMediaContent can return true or false`() {
val sut = aTimelineProtectionState(
protectionState = ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))
)
assertThat(sut.hideMediaContent(null)).isTrue()
assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse()
assertThat(sut.hideMediaContent(AN_EVENT_ID_2)).isTrue()
}
}

View file

@ -17,13 +17,12 @@ import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@ -708,8 +707,6 @@ class VoiceMessageComposerPresenterTest {
) )
} }
private fun aReplyMode() = MessageComposerMode.Reply(replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID))
private fun aVoiceMessageComposerEvent( private fun aVoiceMessageComposerEvent(
isReply: Boolean = false isReply: Boolean = false
) = Composer( ) = Composer(

View file

@ -13,5 +13,6 @@ sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
data class SetSimplifiedSlidingSyncEnabled(val isEnabled: Boolean) : DeveloperSettingsEvents data class SetSimplifiedSlidingSyncEnabled(val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetHideImagesAndVideos(val value: Boolean) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents
} }

View file

@ -71,6 +71,9 @@ class DeveloperSettingsPresenter @Inject constructor(
val isSimplifiedSlidingSyncEnabled by appPreferencesStore val isSimplifiedSlidingSyncEnabled by appPreferencesStore
.isSimplifiedSlidingSyncEnabledFlow() .isSimplifiedSlidingSyncEnabledFlow()
.collectAsState(initial = false) .collectAsState(initial = false)
val hideImagesAndVideos by appPreferencesStore
.doesHideImagesAndVideosFlow()
.collectAsState(initial = false)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
FeatureFlags.entries FeatureFlags.entries
@ -114,6 +117,9 @@ class DeveloperSettingsPresenter @Inject constructor(
appPreferencesStore.setSimplifiedSlidingSyncEnabled(event.isEnabled) appPreferencesStore.setSimplifiedSlidingSyncEnabled(event.isEnabled)
logoutUseCase.logout(ignoreSdkError = true) logoutUseCase.logout(ignoreSdkError = true)
} }
is DeveloperSettingsEvents.SetHideImagesAndVideos -> coroutineScope.launch {
appPreferencesStore.setHideImagesAndVideos(event.value)
}
} }
} }
@ -128,6 +134,7 @@ class DeveloperSettingsPresenter @Inject constructor(
validator = ::customElementCallUrlValidator, validator = ::customElementCallUrlValidator,
), ),
isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled, isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled,
hideImagesAndVideos = hideImagesAndVideos,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -19,6 +19,7 @@ data class DeveloperSettingsState(
val clearCacheAction: AsyncData<Unit>, val clearCacheAction: AsyncData<Unit>,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val isSimpleSlidingSyncEnabled: Boolean, val isSimpleSlidingSyncEnabled: Boolean,
val hideImagesAndVideos: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit val eventSink: (DeveloperSettingsEvents) -> Unit
) )

View file

@ -31,6 +31,7 @@ fun aDeveloperSettingsState(
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized, clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
isSimplifiedSlidingSyncEnabled: Boolean = false, isSimplifiedSlidingSyncEnabled: Boolean = false,
hideImagesAndVideos: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {}, eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState( ) = DeveloperSettingsState(
features = aFeatureUiModelList(), features = aFeatureUiModelList(),
@ -39,6 +40,7 @@ fun aDeveloperSettingsState(
clearCacheAction = clearCacheAction, clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState, customElementCallBaseUrlState = customElementCallBaseUrlState,
isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled, isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled,
hideImagesAndVideos = hideImagesAndVideos,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -40,9 +40,10 @@ fun DeveloperSettingsView(
title = stringResource(id = CommonStrings.common_developer_options) title = stringResource(id = CommonStrings.common_developer_options)
) { ) {
// Note: this is OK to hardcode strings in this debug screen. // Note: this is OK to hardcode strings in this debug screen.
SettingsCategory(state)
PreferenceCategory( PreferenceCategory(
title = "Feature flags", title = "Feature flags",
showTopDivider = false, showTopDivider = true,
) { ) {
FeatureListContent(state) 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 @Composable
private fun ElementCallCategory( private fun ElementCallCategory(
state: DeveloperSettingsState, state: DeveloperSettingsState,

View file

@ -50,6 +50,7 @@ class DeveloperSettingsPresenterTest {
assertThat(initialState.customElementCallBaseUrlState).isNotNull() assertThat(initialState.customElementCallBaseUrlState).isNotNull()
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull() assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse() assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
assertThat(initialState.hideImagesAndVideos).isFalse()
val loadedState = awaitItem() val loadedState = awaitItem()
assertThat(loadedState.rageshakeState.isEnabled).isFalse() assertThat(loadedState.rageshakeState.isEnabled).isFalse()
assertThat(loadedState.rageshakeState.isSupported).isTrue() 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( private fun createDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),

View file

@ -45,6 +45,7 @@ class DeveloperSettingsViewTest {
} }
} }
@Config(qualifiers = "h1500dp")
@Test @Test
fun `clicking on element call url open the dialogs and submit emits the expected event`() { fun `clicking on element call url open the dialogs and submit emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>() val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
@ -113,6 +114,18 @@ class DeveloperSettingsViewTest {
rule.onNodeWithText("Enable Simplified Sliding Sync").performClick() rule.onNodeWithText("Enable Simplified Sliding Sync").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true)) eventsRecorder.assertSingle(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
} }
@Test
fun `clicking on the hide images and videos switch emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
rule.onNodeWithText("Hide image & video previews").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView(

View file

@ -145,7 +145,7 @@ data class AttachmentThumbnailInfo(
@Composable @Composable
internal fun AttachmentThumbnailPreview(@PreviewParameter(AttachmentThumbnailInfoProvider::class) data: AttachmentThumbnailInfo) = ElementPreview { internal fun AttachmentThumbnailPreview(@PreviewParameter(AttachmentThumbnailInfoProvider::class) data: AttachmentThumbnailInfo) = ElementPreview {
AttachmentThumbnail( AttachmentThumbnail(
data, info = data,
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))

View file

@ -57,11 +57,11 @@ internal sealed interface InReplyToMetadata {
* Metadata can be either a thumbnail with a text OR just a text. * Metadata can be either a thumbnail with a text OR just a text.
*/ */
@Composable @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 MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail( is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo( AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source, thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage },
textContent = eventContent.body, textContent = eventContent.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash, blurHash = type.info?.blurhash,
@ -69,7 +69,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event
) )
is VideoMessageType -> InReplyToMetadata.Thumbnail( is VideoMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo( AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body, textContent = eventContent.body,
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash, blurHash = type.info?.blurhash,
@ -77,7 +77,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event
) )
is FileMessageType -> InReplyToMetadata.Thumbnail( is FileMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo( AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body, textContent = eventContent.body,
type = AttachmentThumbnailType.File, type = AttachmentThumbnailType.File,
) )
@ -104,7 +104,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event
} }
is StickerContent -> InReplyToMetadata.Thumbnail( is StickerContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo( AttachmentThumbnailInfo(
thumbnailSource = eventContent.source, thumbnailSource = eventContent.source.takeUnless { hideImage },
textContent = eventContent.body, textContent = eventContent.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash, blurHash = eventContent.info.blurhash,

View file

@ -48,6 +48,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun InReplyToView( fun InReplyToView(
inReplyTo: InReplyToDetails, inReplyTo: InReplyToDetails,
hideImage: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (inReplyTo) { when (inReplyTo) {
@ -55,7 +56,7 @@ fun InReplyToView(
ReplyToReadyContent( ReplyToReadyContent(
senderId = inReplyTo.senderId, senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile, senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(), metadata = inReplyTo.metadata(hideImage),
modifier = modifier modifier = modifier
) )
} }
@ -191,5 +192,8 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview {
InReplyToView(inReplyTo) InReplyToView(
inReplyTo = inReplyTo,
hideImage = false,
)
} }

View file

@ -61,7 +61,7 @@ class InReplyToMetadataKtTest {
@Test @Test
fun `any message content`() = runTest { fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata() anInReplyToDetailsReady(eventContent = aMessageContent()).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent")) assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
@ -82,7 +82,7 @@ class InReplyToMetadataKtTest {
info = anImageInfo(), info = anImageInfo(),
) )
) )
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( 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 @Test
fun `a sticker message content`() = runTest { fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -108,7 +138,7 @@ class InReplyToMetadataKtTest {
info = anImageInfo(), info = anImageInfo(),
source = aMediaSource(url = "url") source = aMediaSource(url = "url")
) )
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( 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 @Test
fun `a video message content`() = runTest { fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -138,7 +194,7 @@ class InReplyToMetadataKtTest {
info = aVideoInfo(), info = aVideoInfo(),
) )
) )
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( 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 @Test
fun `a file message content`() = runTest { fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -171,7 +257,7 @@ class InReplyToMetadataKtTest {
), ),
) )
) )
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( 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 @Test
fun `a audio message content`() = runTest { fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -203,7 +322,7 @@ class InReplyToMetadataKtTest {
), ),
) )
) )
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( assertThat(it).isEqualTo(
@ -231,7 +350,7 @@ class InReplyToMetadataKtTest {
description = null, description = null,
) )
) )
).metadata() ).metadata(hideImage = false)
} }
}.test { }.test {
awaitItem().let { awaitItem().let {
@ -262,7 +381,7 @@ class InReplyToMetadataKtTest {
details = null, details = null,
) )
) )
).metadata() ).metadata(hideImage = false)
} }
}.test { }.test {
awaitItem().let { awaitItem().let {
@ -285,7 +404,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = aPollContent() eventContent = aPollContent()
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo( assertThat(it).isEqualTo(
@ -307,7 +426,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = RedactedContent eventContent = RedactedContent
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Redacted) assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
@ -320,7 +439,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt) assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
@ -333,7 +452,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = FailedToParseMessageLikeContent("", "") eventContent = FailedToParseMessageLikeContent("", "")
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -346,7 +465,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = FailedToParseStateContent("", "", "") eventContent = FailedToParseStateContent("", "", "")
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -359,7 +478,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = ProfileChangeContent("", "", "", "") eventContent = ProfileChangeContent("", "", "", "")
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -372,7 +491,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = RoomMembershipContent(A_USER_ID, null, null) eventContent = RoomMembershipContent(A_USER_ID, null, null)
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -385,7 +504,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = StateContent("", OtherState.RoomJoinRules) eventContent = StateContent("", OtherState.RoomJoinRules)
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -398,7 +517,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = UnknownContent eventContent = UnknownContent
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -411,7 +530,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = null eventContent = null
).metadata() ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()

View file

@ -22,5 +22,8 @@ interface AppPreferencesStore {
suspend fun setSimplifiedSlidingSyncEnabled(enabled: Boolean) suspend fun setSimplifiedSlidingSyncEnabled(enabled: Boolean)
fun isSimplifiedSlidingSyncEnabledFlow(): Flow<Boolean> fun isSimplifiedSlidingSyncEnabledFlow(): Flow<Boolean>
suspend fun setHideImagesAndVideos(value: Boolean)
fun doesHideImagesAndVideosFlow(): Flow<Boolean>
suspend fun reset() suspend fun reset()
} }

View file

@ -30,6 +30,7 @@ private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme") private val themeKey = stringPreferencesKey("theme")
private val simplifiedSlidingSyncKey = booleanPreferencesKey("useSimplifiedSlidingSync") private val simplifiedSlidingSyncKey = booleanPreferencesKey("useSimplifiedSlidingSync")
private val hideImagesAndVideosKey = booleanPreferencesKey("hideImagesAndVideos")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore @Inject constructor( 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<Boolean> {
return store.data.map { prefs ->
prefs[hideImagesAndVideosKey] ?: false
}
}
override suspend fun reset() { override suspend fun reset() {
store.edit { it.clear() } store.edit { it.clear() }
} }

View file

@ -13,11 +13,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
hideImagesAndVideos: Boolean = false,
customElementCallBaseUrl: String? = null, customElementCallBaseUrl: String? = null,
theme: String? = null, theme: String? = null,
simplifiedSlidingSyncEnabled: Boolean = false simplifiedSlidingSyncEnabled: Boolean = false
) : AppPreferencesStore { ) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val hideImagesAndVideos = MutableStateFlow(hideImagesAndVideos)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme) private val theme = MutableStateFlow(theme)
private val simplifiedSlidingSyncEnabled = MutableStateFlow(simplifiedSlidingSyncEnabled) private val simplifiedSlidingSyncEnabled = MutableStateFlow(simplifiedSlidingSyncEnabled)
@ -54,6 +56,14 @@ class InMemoryAppPreferencesStore(
return simplifiedSlidingSyncEnabled return simplifiedSlidingSyncEnabled
} }
override suspend fun setHideImagesAndVideos(value: Boolean) {
hideImagesAndVideos.value = value
}
override fun doesHideImagesAndVideosFlow(): Flow<Boolean> {
return hideImagesAndVideos
}
override suspend fun reset() { override suspend fun reset() {
// No op // No op
} }

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -45,6 +46,7 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -69,6 +71,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver, private val callNotificationEventResolver: CallNotificationEventResolver,
private val appPreferencesStore: AppPreferencesStore,
) : NotifiableEventResolver { ) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? { override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
// Restore session // Restore session
@ -103,7 +106,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
timestamp = this.timestamp, timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = messageBody, body = messageBody,
imageUriString = fetchImageIfPresent(client)?.toString(), imageUriString = content.fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName, roomName = roomDisplayName,
roomIsDm = isDm, roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl, roomAvatarPath = roomAvatarUrl,
@ -148,7 +151,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
timestamp = this.timestamp, timestamp = this.timestamp,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = stringProvider.getString(CommonStrings.common_call_invite), body = stringProvider.getString(CommonStrings.common_call_invite),
imageUriString = fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName, roomName = roomDisplayName,
roomIsDm = isDm, roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl, roomAvatarPath = roomAvatarUrl,
@ -288,22 +290,21 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
} }
private suspend fun NotificationData.fetchImageIfPresent(client: MatrixClient): Uri? { private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
val fileResult = when (val content = this.content) { if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) {
is NotificationContent.MessageLike.RoomMessage -> { return null
when (val messageType = content.messageType) { }
is ImageMessageType -> notificationMediaRepoFactory.create(client) val fileResult = when (val messageType = messageType) {
.getMediaFile( is ImageMessageType -> notificationMediaRepoFactory.create(client)
mediaSource = messageType.source, .getMediaFile(
mimeType = messageType.info?.mimetype, mediaSource = messageType.source,
body = messageType.body, mimeType = messageType.info?.mimetype,
) body = messageType.body,
is VideoMessageType -> null // Use the thumbnail here? )
else -> null is VideoMessageType -> null // Use the thumbnail here?
}
}
else -> null else -> null
} ?: return null }
?: return null
return fileResult return fileResult
.onFailure { .onFailure {

View file

@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
@ -798,6 +800,7 @@ class DefaultNotifiableEventResolverTest {
private fun createDefaultNotifiableEventResolver( private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(), notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<NotificationData?> = Result.success(null), notificationResult: Result<NotificationData?> = Result.success(null),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
): DefaultNotifiableEventResolver { ): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context val context = RuntimeEnvironment.getApplication() as Context
notificationService?.givenGetNotificationResult(notificationResult) notificationService?.givenGetNotificationResult(notificationResult)
@ -821,6 +824,7 @@ class DefaultNotifiableEventResolverTest {
callNotificationEventResolver = DefaultCallNotificationEventResolver( callNotificationEventResolver = DefaultCallNotificationEventResolver(
stringProvider = AndroidStringProvider(context.resources) stringProvider = AndroidStringProvider(context.resources)
), ),
appPreferencesStore = appPreferencesStore,
) )
} }
} }

View file

@ -48,6 +48,7 @@ internal fun ComposerModeView(
ReplyToModeView( ReplyToModeView(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
replyToDetails = composerMode.replyToDetails, replyToDetails = composerMode.replyToDetails,
hideImage = composerMode.hideImage,
onResetComposerMode = onResetComposerMode, onResetComposerMode = onResetComposerMode,
) )
} }
@ -103,6 +104,7 @@ private fun EditingModeView(
@Composable @Composable
private fun ReplyToModeView( private fun ReplyToModeView(
replyToDetails: InReplyToDetails, replyToDetails: InReplyToDetails,
hideImage: Boolean,
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -112,7 +114,11 @@ private fun ReplyToModeView(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(4.dp) .padding(4.dp)
) { ) {
InReplyToView(inReplyTo = replyToDetails, modifier = Modifier.weight(1f)) InReplyToView(
inReplyTo = replyToDetails,
hideImage = hideImage,
modifier = Modifier.weight(1f),
)
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close), contentDescription = stringResource(CommonStrings.action_close),

View file

@ -118,8 +118,8 @@ fun TextComposer(
} }
val layoutModifier = modifier val layoutModifier = modifier
.fillMaxSize() .fillMaxSize()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember { val composerOptionsButton: @Composable () -> Unit = remember {
@Composable { @Composable {
@ -316,8 +316,8 @@ private fun StandardLayout(
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp), .size(48.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
voiceDeleteButton() voiceDeleteButton()
@ -327,8 +327,8 @@ private fun StandardLayout(
} }
Box( Box(
modifier = Modifier modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp) .padding(bottom = 8.dp, top = 8.dp)
.weight(1f) .weight(1f)
) { ) {
voiceRecording() voiceRecording()
} }
@ -341,16 +341,16 @@ private fun StandardLayout(
} }
Box( Box(
modifier = Modifier modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp) .padding(bottom = 8.dp, top = 8.dp)
.weight(1f) .weight(1f)
) { ) {
textInput() textInput()
} }
} }
Box( Box(
Modifier Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp), .size(48.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
endButton() endButton()
@ -372,8 +372,8 @@ private fun TextFormattingLayout(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) { ) {
textInput() textInput()
} }
@ -417,21 +417,24 @@ private fun TextInputBox(
Column( Column(
modifier = Modifier modifier = Modifier
.clip(roundedCorners) .clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners) .border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor) .background(color = bgColor)
.requiredHeightIn(min = 42.dp) .requiredHeightIn(min = 42.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
if (composerMode is MessageComposerMode.Special) { if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) ComposerModeView(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
)
} }
val defaultTypography = ElementTheme.typography.fontBodyLgRegular val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) .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 // 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), .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
// Placeholder // Placeholder
@ -477,8 +480,8 @@ private fun TextInput(
// This prevents it gaining focus and mutating the state. // This prevents it gaining focus and mutating the state.
registerStateUpdates = !subcomposing, registerStateUpdates = !subcomposing,
modifier = Modifier modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp) .padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(), .fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay,
@ -603,6 +606,7 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply( composerMode = MessageComposerMode.Reply(
replyToDetails = inReplyToDetails, replyToDetails = inReplyToDetails,
hideImage = false,
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
) )

View file

@ -27,7 +27,8 @@ sealed interface MessageComposerMode {
) : Special ) : Special
data class Reply( data class Reply(
val replyToDetails: InReplyToDetails val replyToDetails: InReplyToDetails,
val hideImage: Boolean,
) : Special { ) : Special {
val eventId: EventId = replyToDetails.eventId() val eventId: EventId = replyToDetails.eventId()
} }

View file

@ -104,6 +104,7 @@
<string name="action_send_message">"Send message"</string> <string name="action_send_message">"Send message"</string>
<string name="action_share">"Share"</string> <string name="action_share">"Share"</string>
<string name="action_share_link">"Share link"</string> <string name="action_share_link">"Share link"</string>
<string name="action_show">"Show"</string>
<string name="action_sign_in_again">"Sign in again"</string> <string name="action_sign_in_again">"Sign in again"</string>
<string name="action_signout">"Sign out"</string> <string name="action_signout">"Sign out"</string>
<string name="action_signout_anyway">"Sign out anyway"</string> <string name="action_signout_anyway">"Sign out anyway"</string>

View file

@ -118,6 +118,8 @@ class KonsistPreviewTest {
"TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithReplyPreview",
"TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentCollapsePreview",
"TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemGroupedEventsRowContentExpandedPreview",
"TimelineItemImageViewHideMediaContentPreview",
"TimelineItemVideoViewHideMediaContentPreview",
"TimelineItemVoiceViewUnifiedPreview", "TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview", "TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview", "TimelineViewMessageShieldPreview",

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78ea6cbfcf12e405eca8b953b3d847e73f80121ad47beb6346563a2e9b5d567b
size 56342

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:047da66e7f7e78478b1d8442028224073e3d31493b5facf926472fc526532be1
size 56473

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 3642

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 3659

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dac7e47f0e994457ed3db3add1a982f6dbecbd24533b4dca25b6b58b8a25a39d oid sha256:bee6962a35cc2da956be0488f446c9a3d8831cec97f30e0828440068f764a0d1
size 36847 size 34241

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d2615fb7e5541a393546f647e2f0534dcab5cc807eabec5d900ffbc571493932 oid sha256:f08bf0cad0c94da75a0efb97dc92bc3e3c36cde4d833a6a26f9b0d63887785bf
size 50069 size 47453

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:60f34ea24588048379282997eba41bc269a63a2d48d95fd9f83053adf22adc30 oid sha256:0d5253e52390f9dcdd46db4f8d98d26be7cc8d05176d707a8a8c10adf4e7307b
size 62067 size 57064

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 3642

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6abd680ddf4abfeeb06968624a2d6f7468031d4774f39fccf6e6bf767d957142 oid sha256:d37431be2add3d26b6fd41a94a4c2970d3303de8d39154f1b36f33ec4ae6bf44
size 36814 size 34260

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2ab8cf4eeb0aedb2061b7a7b2c762d3c0113a378872cfbb44d456c5c765790b5 oid sha256:1a7cdbdd2fac32dcf3c08dcd2abdc8ec96c7b5d56bdfafb5b8594e84ad5da884
size 49975 size 47375

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:784d117799cee721b3efdf2eb8826642843e44ea893f2f5f2148d29b251a9537 oid sha256:f0f597867f02b8c31fc094f02b39bea68a00b553cb999e3231f4d046e09da9a0
size 61573 size 56893

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 3659

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78ea6cbfcf12e405eca8b953b3d847e73f80121ad47beb6346563a2e9b5d567b
size 56342

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:047da66e7f7e78478b1d8442028224073e3d31493b5facf926472fc526532be1
size 56473

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:351b2b559fbe17a1de36c51e63171230b6807702c6b56233ba584ca65ff5eff9
size 5015

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:13e417d71098d8c863e72753263dfa27f7b398ed5ecb6461f7dde7081fd622fa
size 4801

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db067468af10a72fbaa437a00937afddba100f5345317b9c23b03c8920d5cffa
size 33401

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0fc9f20bae5c54d807f5917844669bb8909514483d2ad29b348ef18f8f986a6
size 33356

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fb9971d6aa7f0734f9a30f83b25a03519f97789618219f0c79efa87d4430ca0f oid sha256:0dda72bce08ceff2d577feabb88d097dc0af0de2bd6b24261adf39bed92a5157
size 58918 size 57652

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fb9971d6aa7f0734f9a30f83b25a03519f97789618219f0c79efa87d4430ca0f oid sha256:0dda72bce08ceff2d577feabb88d097dc0af0de2bd6b24261adf39bed92a5157
size 58918 size 57652

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f44fc0dc3cd92839a7b176b086be75b082a238b9c8cd197f7273b33e7f01c591 oid sha256:1ed1eeca50499ba467db8d2b17d334becb68cc9ea7418286eee8e19aeab3f9cb
size 57495 size 56240

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:93a6d6723bc89b9660a440848461c1568004d312599d7d2d2b94299d6aa47c0a oid sha256:ccc5bf3169fbbd19626c775c88e295b48192f5a54b5640c99dfeca813e8e7ca5
size 57037 size 55838

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:93a6d6723bc89b9660a440848461c1568004d312599d7d2d2b94299d6aa47c0a oid sha256:ccc5bf3169fbbd19626c775c88e295b48192f5a54b5640c99dfeca813e8e7ca5
size 57037 size 55838

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bd7e58a29f370733af1e35531f428d5d2a732c70b9156d66ba590c9c5203f5d6 oid sha256:266125ef6b0812194472e6efb5c832210523e0bc1538408f872ef5c48fc3906d
size 55689 size 54434