Add settings to hide images and videos in the timeline.

Hide images, videos and stickers in the timeline.
Disable click on hidden content. It must be revealed first.
Add preview without BlurHash.
Also hide image in thumbnails.
This commit is contained in:
Benoit Marty 2024-10-02 19:47:44 +02:00
parent 98d9abecd9
commit dd2a1b3388
48 changed files with 775 additions and 140 deletions

View file

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

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

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

View file

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

View file

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

View file

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

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.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@ -26,6 +27,7 @@ sealed interface PinnedMessagesListState {
data object Empty : PinnedMessagesListState
data class Filled(
val timelineRoomInfo: TimelineRoomInfo,
val timelineProtectionState: TimelineProtectionState,
val userEventPermissions: UserEventPermissions,
val timelineItems: ImmutableList<TimelineItem>,
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.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -83,12 +85,14 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
eventSink: (PinnedMessagesListEvents) -> Unit = {}
) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
@ -77,8 +79,8 @@ fun PinnedMessagesListView(
onLinkClick = onLinkClick,
onErrorDismiss = onBackClick,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
.padding(padding)
.consumeWindowInsets(padding),
)
}
)
@ -208,6 +210,7 @@ private fun PinnedMessagesListLoaded(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
isLastOutgoingMessage = false,
focusedEventId = null,
onUserDataClick = onUserDataClick,
@ -225,6 +228,7 @@ private fun PinnedMessagesListLoaded(
eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper(
event = event,
timelineProtectionState = state.timelineProtectionState,
onLinkClick = onLinkClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
@ -238,6 +242,7 @@ private fun PinnedMessagesListLoaded(
@Composable
private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onLinkClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
@ -251,6 +256,8 @@ private fun TimelineItemEventContentViewWrapper(
} else {
TimelineItemEventContentView(
content = event.content,
hideContent = timelineProtectionState.hideContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = { },
modifier = modifier,

View file

@ -243,8 +243,8 @@ class TimelinePresenter @AssistedInject constructor(
}
}
return TimelineState(
timelineRoomInfo = timelineRoomInfo,
timelineItems = timelineItems,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
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.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -72,6 +74,7 @@ import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit,
@ -137,6 +140,7 @@ fun TimelineView(
TimelineItemRow(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
@ -320,6 +324,7 @@ internal fun TimelineViewPreview(
),
focusedEventIndex = 0,
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onMessageClick = {},

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

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

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

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

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

View file

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

View file

@ -25,12 +25,14 @@ fun TimelineItemAspectRatioBox(
aspectRatio: Float?,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
minHeight: Int = MIN_HEIGHT_IN_DP,
maxHeight: Int = MAX_HEIGHT_IN_DP,
content: @Composable (BoxScope.() -> Unit),
) {
val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO
Box(
modifier = modifier
.heightIn(min = MIN_HEIGHT_IN_DP.dp, max = MAX_HEIGHT_IN_DP.dp)
.heightIn(min = minHeight.dp, max = maxHeight.dp)
.aspectRatio(safeAspectRatio, false),
contentAlignment = contentAlignment,
content = content

View file

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

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

View file

@ -7,44 +7,84 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@Composable
fun TimelineItemStickerView(
content: TimelineItemStickerContent,
hideContent: Boolean,
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
Box(
modifier = modifier
.heightIn(min = STICKER_SIZE_IN_DP.dp, max = STICKER_SIZE_IN_DP.dp)
.aspectRatio(aspectRatio, false),
contentAlignment = Alignment.TopStart,
val description = content.body.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
Column(
modifier = modifier.semantics { contentDescription = description },
) {
BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurhash,
)
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
minHeight = STICKER_SIZE_IN_DP,
maxHeight = STICKER_SIZE_IN_DP,
) {
ProtectedView(
hideContent = hideContent,
onShowClick = onShowClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
body = content.body,
mimeType = content.mimeType,
),
),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview {
TimelineItemStickerView(content)
TimelineItemStickerView(
content = content,
hideContent = false,
onShowClick = {},
)
}

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.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -64,6 +65,8 @@ import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,
hideContent: Boolean,
onShowClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@ -83,33 +86,38 @@ fun TimelineItemVideoView(
aspectRatio = content.aspectRatio,
contentAlignment = Alignment.Center,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(
source = content.thumbnailSource,
kind = MediaRequestData.Kind.File(
body = content.filename ?: content.body,
mimeType = content.mimeType
)
),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
ProtectedView(
hideContent = hideContent,
onShowClick = onShowClick,
) {
Image(
Icons.Default.PlayArrow,
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(
source = content.thumbnailSource,
kind = MediaRequestData.Kind.File(
body = content.filename ?: content.body,
mimeType = content.mimeType
)
),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
Icons.Default.PlayArrow,
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
)
}
}
}
@ -141,7 +149,23 @@ fun TimelineItemVideoView(
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
TimelineItemVideoView(content, {})
TimelineItemVideoView(
content = content,
hideContent = false,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewHideContentPreview() = ElementPreview {
TimelineItemVideoView(
content = aTimelineItemVideoContent(),
hideContent = true,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.protection
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.theme.components.Button
@Composable
fun BoxScope.ProtectedView(
hideContent: Boolean,
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
if (hideContent) {
// TODO Update design, wording for video?
Button(
modifier = modifier.align(Alignment.Center),
text = "Show",
onClick = onShowClick,
)
} else {
content()
}
}

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 hideContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false)
var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) }
val protectionState by remember(hideContent) {
derivedStateOf {
if (hideContent) {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
} else {
ProtectionState.RenderAll
}
}
}
fun handleEvent(event: TimelineProtectionEvent) {
when (event) {
is TimelineProtectionEvent.ShowContent -> {
allowedEvents = allowedEvents + setOfNotNull(event.eventId)
}
}
}
return TimelineProtectionState(
protectionState = protectionState,
eventSink = { event -> handleEvent(event) }
)
}
}

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 hideContent(eventId: EventId?) = when (protectionState) {
is ProtectionState.RenderAll -> false
is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds
}
}
@Immutable
sealed interface ProtectionState {
data object RenderAll : ProtectionState
data class RenderOnly(val eventIds: ImmutableSet<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

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

View file

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

View file

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

View file

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

View file

@ -40,9 +40,10 @@ fun DeveloperSettingsView(
title = stringResource(id = CommonStrings.common_developer_options)
) {
// Note: this is OK to hardcode strings in this debug screen.
SettingsCategory(state)
PreferenceCategory(
title = "Feature flags",
showTopDivider = false,
showTopDivider = true,
) {
FeatureListContent(state)
}
@ -92,6 +93,22 @@ fun DeveloperSettingsView(
}
}
@Composable
private fun SettingsCategory(
state: DeveloperSettingsState,
) {
PreferenceCategory(title = "Preferences", showTopDivider = false) {
PreferenceSwitch(
title = "Hide image & video previews",
subtitle = "When toggled image & video will not render in the timeline by default.",
isChecked = state.hideImagesAndVideos,
onCheckedChange = {
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(it))
}
)
}
}
@Composable
private fun ElementCallCategory(
state: DeveloperSettingsState,

View file

@ -50,6 +50,7 @@ class DeveloperSettingsPresenterTest {
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
assertThat(initialState.hideImagesAndVideos).isFalse()
val loadedState = awaitItem()
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
assertThat(loadedState.rageshakeState.isSupported).isTrue()
@ -179,6 +180,24 @@ class DeveloperSettingsPresenterTest {
}
}
@Test
fun `present - toggling hide image and video`() = runTest {
val preferences = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.hideImagesAndVideos).isFalse()
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
assertThat(awaitItem().hideImagesAndVideos).isTrue()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
assertThat(awaitItem().hideImagesAndVideos).isFalse()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
}
}
private fun createDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),

View file

@ -45,6 +45,7 @@ class DeveloperSettingsViewTest {
}
}
@Config(qualifiers = "h1500dp")
@Test
fun `clicking on element call url open the dialogs and submit emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
@ -113,6 +114,18 @@ class DeveloperSettingsViewTest {
rule.onNodeWithText("Enable Simplified Sliding Sync").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
}
@Test
fun `clicking on the hide images and videos switch emits the expected event`() {
val eventsRecorder = EventsRecorder<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(

View file

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

View file

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

View file

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

View file

@ -61,7 +61,7 @@ class InReplyToMetadataKtTest {
@Test
fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata()
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
@ -82,7 +82,7 @@ class InReplyToMetadataKtTest {
info = anImageInfo(),
)
)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -99,6 +99,36 @@ class InReplyToMetadataKtTest {
}
}
@Test
fun `an image message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = ImageMessageType(
body = "body",
formatted = null,
filename = null,
source = aMediaSource(),
info = anImageInfo(),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
@ -108,7 +138,7 @@ class InReplyToMetadataKtTest {
info = anImageInfo(),
source = aMediaSource(url = "url")
)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -125,6 +155,32 @@ class InReplyToMetadataKtTest {
}
}
@Test
fun `a sticker message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = StickerContent(
body = "body",
info = anImageInfo(),
source = aMediaSource(url = "url")
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
@ -138,7 +194,7 @@ class InReplyToMetadataKtTest {
info = aVideoInfo(),
)
)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -155,6 +211,36 @@ class InReplyToMetadataKtTest {
}
}
@Test
fun `a video message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VideoMessageType(
body = "body",
formatted = null,
filename = null,
source = aMediaSource(),
info = aVideoInfo(),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
@ -171,7 +257,7 @@ class InReplyToMetadataKtTest {
),
)
)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -188,6 +274,39 @@ class InReplyToMetadataKtTest {
}
}
@Test
fun `a file message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = FileMessageType(
body = "body",
source = aMediaSource(),
info = FileInfo(
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.File,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
@ -203,7 +322,7 @@ class InReplyToMetadataKtTest {
),
)
)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -231,7 +350,7 @@ class InReplyToMetadataKtTest {
description = null,
)
)
).metadata()
).metadata(hideImage = false)
}
}.test {
awaitItem().let {
@ -262,7 +381,7 @@ class InReplyToMetadataKtTest {
details = null,
)
)
).metadata()
).metadata(hideImage = false)
}
}.test {
awaitItem().let {
@ -285,7 +404,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aPollContent()
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
@ -307,7 +426,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = RedactedContent
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
@ -320,7 +439,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
@ -333,7 +452,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = FailedToParseMessageLikeContent("", "")
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -346,7 +465,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = FailedToParseStateContent("", "", "")
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -359,7 +478,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = ProfileChangeContent("", "", "", "")
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -372,7 +491,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = RoomMembershipContent(A_USER_ID, null, null)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -385,7 +504,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = StateContent("", OtherState.RoomJoinRules)
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -398,7 +517,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = UnknownContent
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -411,7 +530,7 @@ class InReplyToMetadataKtTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = null
).metadata()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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