Add media summary pill.

This commit is contained in:
Alex Hart
2026-07-29 11:42:56 -03:00
parent 3c249e8998
commit a28dfa40cc
5 changed files with 243 additions and 1 deletions
@@ -15,12 +15,14 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.AndroidFragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.LocalDisplayNameProvider
import org.signal.mediasend.HudCommand
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendRecipient
@@ -37,6 +39,7 @@ import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.rememberRecipientField
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -79,6 +82,8 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
}
setContent {
val context = LocalContext.current
CompositionLocalProvider(
LocalAddAMessageRowTextField provides { message, modifier ->
AndroidView(
@@ -88,6 +93,15 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
},
modifier = modifier
)
},
LocalDisplayNameProvider provides { id ->
rememberRecipientField(RecipientId.from(id)) {
if (isUnknown) {
""
} else {
getDisplayName(context)
}
}
}
) {
MediaSendScreen(
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
/**
* Intended to be bound at the app level to allow for the production of an observable display name of a recipient. Raw types are utilized here since
* RecipientId and Recipient are both currently localized to the app module and are non-trivial to pick up and move.
*/
val LocalDisplayNameProvider = compositionLocalOf<@Composable (Long) -> State<String>> {
{ remember(it) { mutableStateOf("DisplayName($it)") } }
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -39,6 +40,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.LocalDisplayNameProvider
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.glide.compose.GlideImage
@@ -55,6 +57,7 @@ import org.signal.mediasend.edit.image.RotationDial
import org.signal.mediasend.edit.video.VideoEditorFragment
import org.signal.mediasend.edit.video.VideoEditorToolbar
import org.signal.mediasend.edit.video.VideoEditorViewModel
import org.signal.mediasend.rememberPreviewState
@Composable
fun MediaEditScreen(
@@ -292,6 +295,19 @@ fun MediaEditScreen(
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
)
}
val displayNameState = state.recipientId?.let { LocalDisplayNameProvider.current(it.id) } ?: remember { mutableStateOf(null) }
val displayName: String? by displayNameState
MediaEditSummaryPill(
displayName = displayName,
selectedMedia = state.selectedMedia,
selectedPage = pagerState.currentPage,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.systemBarsPadding()
)
}
}
@@ -311,7 +327,7 @@ private fun MediaEditScreenPreview() {
Previews.Preview {
MediaEditScreen(
state = MediaSendState(
state = rememberPreviewState().copy(
selectedMedia = selectedMedia,
focusedMedia = selectedMedia.first(),
editorStateMap = mutableMapOf(
@@ -0,0 +1,164 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.signal.core.models.media.Media
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.mediasend.R
@Composable
internal fun MediaEditSummaryPill(
displayName: String?,
selectedMedia: List<Media>,
selectedPage: Int,
modifier: Modifier = Modifier
) {
val selectedMediaText = rememberSelectedMediaText(selectedMedia, selectedPage)
val targetState = remember(displayName, selectedMediaText) {
val hasDisplayName = displayName.isNotNullOrBlank()
val hasSelectedMedia = selectedMediaText.isNotNullOrBlank()
when {
hasDisplayName && hasSelectedMedia -> {
MediaEditSummaryPillTargetState.MultiRow(
row1 = displayName,
row2 = selectedMediaText
)
}
hasDisplayName -> {
MediaEditSummaryPillTargetState.SingleRow(
text = displayName
)
}
hasSelectedMedia -> {
MediaEditSummaryPillTargetState.SingleRow(
text = selectedMediaText
)
}
else -> MediaEditSummaryPillTargetState.None
}
}
Box(
modifier = modifier
) {
when (targetState) {
is MediaEditSummaryPillTargetState.MultiRow -> MultiRowPill(targetState)
MediaEditSummaryPillTargetState.None -> Unit
is MediaEditSummaryPillTargetState.SingleRow -> SingleRowPill(targetState)
}
}
}
@Composable
private fun SingleRowPill(singleRow: MediaEditSummaryPillTargetState.SingleRow) {
Pill(
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 4.dp
)
) {
Text(text = singleRow.text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun MultiRowPill(multiRow: MediaEditSummaryPillTargetState.MultiRow) {
Pill(
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 4.dp
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = multiRow.row1, style = MaterialTheme.typography.bodyMedium)
Text(text = multiRow.row2, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight(400)))
}
}
}
@Composable
private fun Pill(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.background(
color = SignalTheme.colors.colorSurface5,
shape = CircleShape
)
.then(modifier)
) {
content()
}
}
@Composable
fun rememberSelectedMediaText(selectedMedia: List<Media>, selectedPage: Int): String {
if (selectedMedia.isEmpty()) {
return ""
}
if (selectedMedia.size > 1) {
return stringResource(R.string.MediaEditScreen__d_of_d, selectedPage + 1, selectedMedia.size)
}
val media = selectedMedia.first()
val contentType = media.contentType
val label = when {
ContentTypeUtil.isGif(contentType) || media.isVideoGif -> R.plurals.MediaEditScreen__gif
ContentTypeUtil.isImageType(contentType) -> R.plurals.MediaEditScreen__photo
ContentTypeUtil.isVideoType(contentType) -> R.plurals.MediaEditScreen__video
ContentTypeUtil.isDocumentType(contentType) -> R.plurals.MediaEditScreen__document
else -> R.plurals.MediaEditScreen__item
}
return pluralStringResource(label, selectedMedia.size, selectedMedia.size)
}
@DayNightPreviews
@Composable
private fun SingleRowPillPreview() {
Previews.Preview {
SingleRowPill(singleRow = MediaEditSummaryPillTargetState.SingleRow(text = "Single Row"))
}
}
@DayNightPreviews
@Composable
private fun MultiRowPillPreview() {
Previews.Preview {
MultiRowPill(multiRow = MediaEditSummaryPillTargetState.MultiRow(row1 = "Top Row", row2 = "Bottom Row"))
}
}
private sealed interface MediaEditSummaryPillTargetState {
data class SingleRow(val text: String) : MediaEditSummaryPillTargetState
data class MultiRow(val row1: String, val row2: String) : MediaEditSummaryPillTargetState
data object None : MediaEditSummaryPillTargetState
}
@@ -116,4 +116,32 @@
<string name="LinkedDeviceScannedDialog__it_looks_like_youre_trying">It looks like you\'re trying to link a Signal device. Tap continue and then tap \"Link a New Device\" and scan the QR code again.</string>
<!-- The label of a dialog asking the user if they would like to continue to the linked device settings screen. -->
<string name="LinkedDeviceScannedDialog__device_link_dialog_continue">Continue</string>
<!-- Gif label displayed in a pill in the editor specifying the type of media being sent. -->
<plurals name="MediaEditScreen__gif">
<item quantity="one">%1$d Gif</item>
<item quantity="other">%1$d Gifs</item>
</plurals>
<!-- Photo label displayed in a pill in the editor specifying the type of media being sent. -->
<plurals name="MediaEditScreen__photo">
<item quantity="one">%1$d Photo</item>
<item quantity="other">%1$d Photos</item>
</plurals>
<!-- Video label displayed in a pill in the editor specifying the type of media being sent. -->
<plurals name="MediaEditScreen__video">
<item quantity="one">%1$d Video</item>
<item quantity="other">%1$d Videos</item>
</plurals>
<!-- Document label displayed in a pill in the editor specifying the type of media being sent. -->
<plurals name="MediaEditScreen__document">
<item quantity="one">%1$d Document</item>
<item quantity="other">%1$d Documents</item>
</plurals>
<!-- Item label displayed in a pill in the editor specifying the type of media being sent. -->
<plurals name="MediaEditScreen__item">
<item quantity="one">%1$d Item</item>
<item quantity="other">%1$d Items</item>
</plurals>
<!-- x of y label displayed in a pill in the editor specifying the type of media being sent. First placeholder is selected page, second is total count. -->
<string name="MediaEditScreen__d_of_d">%1$d of %2$d</string>
</resources>