diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt index 436fb5b0be..1b7b9e46c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -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( diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/LocalDisplayNameProvider.kt b/core/ui/src/main/java/org/signal/core/ui/compose/LocalDisplayNameProvider.kt new file mode 100644 index 0000000000..42e5912753 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/LocalDisplayNameProvider.kt @@ -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> { + { remember(it) { mutableStateOf("DisplayName($it)") } } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index bbb791c24d..2e0318df79 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -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( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt new file mode 100644 index 0000000000..363c568791 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt @@ -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, + 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, 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 +} diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index 30c0d7163a..5e1d8ebb29 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -116,4 +116,32 @@ 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. Continue + + + + %1$d Gif + %1$d Gifs + + + + %1$d Photo + %1$d Photos + + + + %1$d Video + %1$d Videos + + + + %1$d Document + %1$d Documents + + + + %1$d Item + %1$d Items + + + %1$d of %2$d