Add settings to hide images and videos in the timeline.

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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