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 779c6db508
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.TimelineItemStateContent
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.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
@ -90,6 +91,7 @@ class MessagesPresenter @AssistedInject constructor(
private val composerPresenter: MessageComposerPresenter,
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
timelinePresenterFactory: TimelinePresenter.Factory,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
@ -123,6 +125,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 +185,7 @@ class MessagesPresenter @AssistedInject constructor(
composerState = composerState,
enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
)
}
is MessagesEvents.ToggleReaction -> {
@ -213,6 +217,7 @@ class MessagesPresenter @AssistedInject constructor(
userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,
@ -262,6 +267,7 @@ class MessagesPresenter @AssistedInject constructor(
action: TimelineItemAction,
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
timelineProtectionState: TimelineProtectionState,
enableTextFormatting: Boolean,
timelineState: TimelineState,
) = launch {
@ -271,7 +277,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 +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
timelineController.invokeOnCurrentTimeline {
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(
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

@ -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.pinned.banner.PinnedMessagesBannerPresenter
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.TypingNotificationState
import io.element.android.libraries.architecture.Presenter
@ -30,4 +32,7 @@ interface MessagesModule {
@Binds
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
)
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) }
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.TimelineItemsFactoryConfig
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.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -60,6 +61,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory,
private val appCoroutineScope: CoroutineScope,
@ -97,14 +99,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 +120,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
return pinnedMessagesListState(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
eventSink = ::handleEvents
@ -214,6 +216,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
@Composable
private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit
@ -228,6 +231,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,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = { },
modifier = modifier,

View file

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

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
@ -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.matrix.api.core.EventId
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 kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit,
@ -114,10 +116,6 @@ fun TimelineView(
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
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
@ -137,6 +135,7 @@ fun TimelineView(
TimelineItemRow(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
@ -320,6 +319,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,
hideMediaContent = timelineProtectionState.hideMediaContent(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.hideMediaContent(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,
hideMediaContent = timelineProtectionState.hideMediaContent(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,
hideMediaContent = timelineProtectionState.hideMediaContent(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,
hideMediaContent = timelineProtectionState.hideMediaContent(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.hideMediaContent(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 = {},
hideMediaContent = 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,
hideMediaContent: 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,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
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,
hideMediaContent: 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 = hideMediaContent,
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,
hideMediaContent = false,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = 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,
hideMediaContent: 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 = 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
@Composable
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.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,
hideMediaContent: 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 = hideMediaContent,
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,
hideMediaContent = false,
onShowClick = {},
onContentLayoutChange = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
TimelineItemVideoView(
content = aTimelineItemVideoContent(),
hideMediaContent = 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,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.aTimelineItemPollContent
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.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
@ -1059,12 +1060,12 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenterFactory = timelinePresenterFactory,
timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenterFactory = FakeActionListPresenter.Factory,
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,

View file

@ -1521,4 +1521,7 @@ fun anEditMode(
transactionId: TransactionId? = null,
) = 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.pinned.PinnedEventsTimelineProvider
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.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -309,6 +310,7 @@ class PinnedMessagesListPresenterTest {
room = room,
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
timelineProvider = timelineProvider,
timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory,
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.event.aTimelineItemImageContent
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.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
@ -137,6 +139,7 @@ class TimelineViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
@ -152,6 +155,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
setSafeContent {
TimelineView(
state = state,
timelineProtectionState = timelineProtectionState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
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 com.google.common.truth.Truth.assertThat
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.test.FakeMessageComposerContext
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.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.mediaupload.api.MediaSender
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(
isReply: Boolean = false
) = Composer(

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

@ -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.VoiceMessageType
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.notifications.model.FallbackNotifiableEvent
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.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.first
import timber.log.Timber
import javax.inject.Inject
@ -69,6 +71,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
@ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver,
private val appPreferencesStore: AppPreferencesStore,
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
// Restore session
@ -103,7 +106,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = messageBody,
imageUriString = fetchImageIfPresent(client)?.toString(),
imageUriString = content.fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName,
roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl,
@ -148,7 +151,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
timestamp = this.timestamp,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = stringProvider.getString(CommonStrings.common_call_invite),
imageUriString = fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName,
roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl,
@ -288,22 +290,21 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
}
private suspend fun NotificationData.fetchImageIfPresent(client: MatrixClient): Uri? {
val fileResult = when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
when (val messageType = content.messageType) {
is ImageMessageType -> notificationMediaRepoFactory.create(client)
.getMediaFile(
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype,
body = messageType.body,
)
is VideoMessageType -> null // Use the thumbnail here?
else -> null
}
}
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) {
return null
}
val fileResult = when (val messageType = messageType) {
is ImageMessageType -> notificationMediaRepoFactory.create(client)
.getMediaFile(
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype,
body = messageType.body,
)
is VideoMessageType -> null // Use the thumbnail here?
else -> null
} ?: return null
}
?: return null
return fileResult
.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.aNotificationData
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.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
@ -798,6 +800,7 @@ class DefaultNotifiableEventResolverTest {
private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<NotificationData?> = Result.success(null),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context
notificationService?.givenGetNotificationResult(notificationResult)
@ -821,6 +824,7 @@ class DefaultNotifiableEventResolverTest {
callNotificationEventResolver = DefaultCallNotificationEventResolver(
stringProvider = AndroidStringProvider(context.resources)
),
appPreferencesStore = appPreferencesStore,
)
}
}

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()
}

View file

@ -104,6 +104,7 @@
<string name="action_send_message">"Send message"</string>
<string name="action_share">"Share"</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_signout">"Sign out"</string>
<string name="action_signout_anyway">"Sign out anyway"</string>

View file

@ -118,6 +118,8 @@ class KonsistPreviewTest {
"TimelineItemEventRowWithReplyPreview",
"TimelineItemGroupedEventsRowContentCollapsePreview",
"TimelineItemGroupedEventsRowContentExpandedPreview",
"TimelineItemImageViewHideMediaContentPreview",
"TimelineItemVideoViewHideMediaContentPreview",
"TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"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
oid sha256:dac7e47f0e994457ed3db3add1a982f6dbecbd24533b4dca25b6b58b8a25a39d
size 36847
oid sha256:bee6962a35cc2da956be0488f446c9a3d8831cec97f30e0828440068f764a0d1
size 34241

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60f34ea24588048379282997eba41bc269a63a2d48d95fd9f83053adf22adc30
size 62067
oid sha256:0d5253e52390f9dcdd46db4f8d98d26be7cc8d05176d707a8a8c10adf4e7307b
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
oid sha256:6abd680ddf4abfeeb06968624a2d6f7468031d4774f39fccf6e6bf767d957142
size 36814
oid sha256:d37431be2add3d26b6fd41a94a4c2970d3303de8d39154f1b36f33ec4ae6bf44
size 34260

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:784d117799cee721b3efdf2eb8826642843e44ea893f2f5f2148d29b251a9537
size 61573
oid sha256:f0f597867f02b8c31fc094f02b39bea68a00b553cb999e3231f4d046e09da9a0
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
oid sha256:fb9971d6aa7f0734f9a30f83b25a03519f97789618219f0c79efa87d4430ca0f
size 58918
oid sha256:0dda72bce08ceff2d577feabb88d097dc0af0de2bd6b24261adf39bed92a5157
size 57652

View file

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

View file

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

View file

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

View file

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

View file

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