Merge pull request #3592 from element-hq/feature/bma/hideImages
Add developer setting to hide images in the timeline
This commit is contained in:
commit
779c6db508
86 changed files with 1103 additions and 193 deletions
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ private fun MessagesViewContent(
|
|||
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onMessageClick = onMessageClick,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,8 +243,8 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
return TimelineState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineItems = timelineItems,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ fun TimelineItemStateEventRow(
|
|||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = {},
|
||||
hideMediaContent = false,
|
||||
onShowClick = {},
|
||||
eventSink = eventSink,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ data class DeveloperSettingsState(
|
|||
val clearCacheAction: AsyncData<Unit>,
|
||||
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
|
||||
val isSimpleSlidingSyncEnabled: Boolean,
|
||||
val hideImagesAndVideos: Boolean,
|
||||
val eventSink: (DeveloperSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ class KonsistPreviewTest {
|
|||
"TimelineItemEventRowWithReplyPreview",
|
||||
"TimelineItemGroupedEventsRowContentCollapsePreview",
|
||||
"TimelineItemGroupedEventsRowContentExpandedPreview",
|
||||
"TimelineItemImageViewHideMediaContentPreview",
|
||||
"TimelineItemVideoViewHideMediaContentPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78ea6cbfcf12e405eca8b953b3d847e73f80121ad47beb6346563a2e9b5d567b
|
||||
size 56342
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:047da66e7f7e78478b1d8442028224073e3d31493b5facf926472fc526532be1
|
||||
size 56473
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||
size 3642
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||
size 3659
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dac7e47f0e994457ed3db3add1a982f6dbecbd24533b4dca25b6b58b8a25a39d
|
||||
size 36847
|
||||
oid sha256:bee6962a35cc2da956be0488f446c9a3d8831cec97f30e0828440068f764a0d1
|
||||
size 34241
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2615fb7e5541a393546f647e2f0534dcab5cc807eabec5d900ffbc571493932
|
||||
size 50069
|
||||
oid sha256:f08bf0cad0c94da75a0efb97dc92bc3e3c36cde4d833a6a26f9b0d63887785bf
|
||||
size 47453
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:60f34ea24588048379282997eba41bc269a63a2d48d95fd9f83053adf22adc30
|
||||
size 62067
|
||||
oid sha256:0d5253e52390f9dcdd46db4f8d98d26be7cc8d05176d707a8a8c10adf4e7307b
|
||||
size 57064
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||
size 3642
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6abd680ddf4abfeeb06968624a2d6f7468031d4774f39fccf6e6bf767d957142
|
||||
size 36814
|
||||
oid sha256:d37431be2add3d26b6fd41a94a4c2970d3303de8d39154f1b36f33ec4ae6bf44
|
||||
size 34260
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2ab8cf4eeb0aedb2061b7a7b2c762d3c0113a378872cfbb44d456c5c765790b5
|
||||
size 49975
|
||||
oid sha256:1a7cdbdd2fac32dcf3c08dcd2abdc8ec96c7b5d56bdfafb5b8594e84ad5da884
|
||||
size 47375
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:784d117799cee721b3efdf2eb8826642843e44ea893f2f5f2148d29b251a9537
|
||||
size 61573
|
||||
oid sha256:f0f597867f02b8c31fc094f02b39bea68a00b553cb999e3231f4d046e09da9a0
|
||||
size 56893
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||
size 3659
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78ea6cbfcf12e405eca8b953b3d847e73f80121ad47beb6346563a2e9b5d567b
|
||||
size 56342
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:047da66e7f7e78478b1d8442028224073e3d31493b5facf926472fc526532be1
|
||||
size 56473
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:351b2b559fbe17a1de36c51e63171230b6807702c6b56233ba584ca65ff5eff9
|
||||
size 5015
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:13e417d71098d8c863e72753263dfa27f7b398ed5ecb6461f7dde7081fd622fa
|
||||
size 4801
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:db067468af10a72fbaa437a00937afddba100f5345317b9c23b03c8920d5cffa
|
||||
size 33401
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c0fc9f20bae5c54d807f5917844669bb8909514483d2ad29b348ef18f8f986a6
|
||||
size 33356
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fb9971d6aa7f0734f9a30f83b25a03519f97789618219f0c79efa87d4430ca0f
|
||||
size 58918
|
||||
oid sha256:0dda72bce08ceff2d577feabb88d097dc0af0de2bd6b24261adf39bed92a5157
|
||||
size 57652
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fb9971d6aa7f0734f9a30f83b25a03519f97789618219f0c79efa87d4430ca0f
|
||||
size 58918
|
||||
oid sha256:0dda72bce08ceff2d577feabb88d097dc0af0de2bd6b24261adf39bed92a5157
|
||||
size 57652
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f44fc0dc3cd92839a7b176b086be75b082a238b9c8cd197f7273b33e7f01c591
|
||||
size 57495
|
||||
oid sha256:1ed1eeca50499ba467db8d2b17d334becb68cc9ea7418286eee8e19aeab3f9cb
|
||||
size 56240
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:93a6d6723bc89b9660a440848461c1568004d312599d7d2d2b94299d6aa47c0a
|
||||
size 57037
|
||||
oid sha256:ccc5bf3169fbbd19626c775c88e295b48192f5a54b5640c99dfeca813e8e7ca5
|
||||
size 55838
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:93a6d6723bc89b9660a440848461c1568004d312599d7d2d2b94299d6aa47c0a
|
||||
size 57037
|
||||
oid sha256:ccc5bf3169fbbd19626c775c88e295b48192f5a54b5640c99dfeca813e8e7ca5
|
||||
size 55838
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bd7e58a29f370733af1e35531f428d5d2a732c70b9156d66ba590c9c5203f5d6
|
||||
size 55689
|
||||
oid sha256:266125ef6b0812194472e6efb5c832210523e0bc1538408f872ef5c48fc3906d
|
||||
size 54434
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue