Add view-once affordance and indication to the V3 media edit screen.

This commit is contained in:
Greyson Parrelli
2026-07-31 18:06:04 +00:00
committed by Alex Hart
parent b9eeb2fe84
commit e8f23a5015
15 changed files with 150 additions and 34 deletions
@@ -1430,7 +1430,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
if (SignalStore.labs().getIncognito()) {
items.add(new ActionItem(R.drawable.symbol_view_once_24, "Open Incognito (Labs)", () -> handleOpenIncognito(conversation)));
items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_view_once_24, "Open Incognito (Labs)", () -> handleOpenIncognito(conversation)));
}
}
@@ -416,7 +416,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
getString(R.string.MediaReviewFragment__photo_set_to_view_once)
}
MediaReviewToastPopupWindow.show(controls, R.drawable.symbol_view_once_24, description)
MediaReviewToastPopupWindow.show(controls, CoreUiR.drawable.symbol_view_once_24, description)
}
private fun presentQualityToggleToast(state: MediaSelectionState) {
@@ -100,8 +100,7 @@ class MediaSendV3Activity :
supportFragmentManager.setFragmentResultListener(AddMessageDialogFragment.REQUEST_KEY, this) { _, bundle ->
if (bundle.getBoolean(AddMessageDialogFragment.RESULT_INCREMENT_VIEW_ONCE_STATE)) {
viewModel.setMessage(null)
viewModel.incrementViewOnceState()
viewModel.toggleViewOnce()
} else {
viewModel.setMessage(bundle.getCharSequence(AddMessageDialogFragment.RESULT_MESSAGE, null))
}
@@ -15,7 +15,6 @@ import org.signal.core.util.getParcelableArrayListCompat
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendRecipient
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.MediaSendViewModel
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -47,7 +46,7 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit
title = R.string.MediaReviewFragment__send_to,
storySendRequirements = state.storySendRequirements.toAppSendRequirements(),
isSearchEnabled = !state.isStory,
isViewOnce = state.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
isViewOnce = state.isViewOnceEnabled
)
)
@@ -123,12 +123,12 @@ public class ViewOnceMessageView extends LinearLayout {
} else {
iconColor = unopenedIconColor;
text.setText(R.string.RevealableMessageView_media);
icon.setImageResource(R.drawable.symbol_view_once_24);
icon.setImageResource(org.signal.core.ui.R.drawable.symbol_view_once_24);
}
} else if (ViewOnceUtil.isViewable(messageRecord)) {
iconColor = unopenedIconColor;
text.setText(getDescriptionId(messageRecord));
icon.setImageResource(R.drawable.symbol_view_once_24);
icon.setImageResource(org.signal.core.ui.R.drawable.symbol_view_once_24);
} else if (networkInProgress(messageRecord)) {
iconColor = unopenedIconColor;
text.setText("");
@@ -92,6 +92,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
TransferDisplay(icon(R.drawable.symbol_transfer_display_48)),
Trash(icon(R.drawable.symbol_trash_24)),
Undo(icon(R.drawable.symbol_undo_24)),
ViewOnce(icon(R.drawable.symbol_view_once_24)),
ViewOnceInfinite(icon(R.drawable.symbol_view_once_infinite_24)),
X(icon(R.drawable.symbol_x_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
}
@@ -15,6 +15,7 @@ import org.signal.camera.CameraDependencies
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import org.signal.core.models.parcelers.NullableCharSequenceParceler
import org.signal.core.util.ContentTypeUtil
import org.signal.mediasend.edit.image.BrushWidths
import org.signal.mediasend.edit.video.VideoTrimData
import kotlin.time.Duration
@@ -140,6 +141,19 @@ data class MediaSendState(
val brushWidths: BrushWidths = MediaSendDependencies.mediaSendRepository.brushWidths
) : Parcelable {
/**
* View-once only makes sense for a single, non-document attachment that isn't headed for a story.
*/
val isViewOnceAvailable: Boolean
get() = selectedMedia.size == 1 && !isStory && !ContentTypeUtil.isDocumentType(focusedMedia?.contentType)
/**
* Whether the current selection will actually be sent as view-once. [viewOnceToggleState] is sticky, so it can
* outlive the conditions that allowed it to be set, and must always be read alongside [isViewOnceAvailable].
*/
val isViewOnceEnabled: Boolean
get() = isViewOnceAvailable && viewOnceToggleState == ViewOnceToggleState.ONCE
fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData {
return (editorStateMap[uri] as? EditorState.VideoTrim)?.videoTrimData ?: VideoTrimData()
}
@@ -292,7 +292,7 @@ class MediaSendViewModel(
HudCommand.ShowAddAMessageDialog(
message = snapshot.message ?: "",
startWithEmojiKeyboard = mediaEditScreenEvent.startWithEmojiKeyboard,
isViewOnceAvailable = snapshot.selectedMedia.size == 1 && !snapshot.isStory && !ContentTypeUtil.isDocumentType(snapshot.focusedMedia?.contentType)
isViewOnceAvailable = snapshot.isViewOnceAvailable
)
)
}
@@ -309,6 +309,10 @@ class MediaSendViewModel(
setSentMediaQuality(state.value.sentMediaQuality.next())
}
MediaEditScreenEvent.ToggleViewOnce -> {
toggleViewOnce()
}
MediaEditScreenEvent.SaveMedia -> {
saveFocusedMediaToStorage()
}
@@ -969,14 +973,37 @@ class MediaSendViewModel(
//region View Once
fun incrementViewOnceState() {
updateState { copy(viewOnceToggleState = viewOnceToggleState.next()) }
fun isViewOnceEnabled(): Boolean {
return internalState.value.isViewOnceEnabled
}
fun isViewOnceEnabled(): Boolean {
val snapshot = internalState.value
return snapshot.selectedMedia.size == 1 &&
snapshot.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
/**
* Flips view-once. Turning it on drops any message the user had already typed, since a view-once send cannot carry
* a body, and confirms the change with a snackbar.
*/
fun toggleViewOnce() {
updateState { copy(viewOnceToggleState = viewOnceToggleState.next()) }
if (!internalState.value.isViewOnceEnabled) {
return
}
setMessage(null)
val focusedMedia = internalState.value.focusedMedia
val isVideo = focusedMedia != null &&
ContentTypeUtil.isVideoType(focusedMedia.contentType) &&
!focusedMedia.isVideoGif
internalSnackbarEvents.trySend(
SnackbarEvent(
message = if (isVideo) {
R.string.MediaSendViewModel__video_set_to_view_once
} else {
R.string.MediaSendViewModel__photo_set_to_view_once
}
)
)
}
//endregion
@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.DropdownMenus
@@ -57,7 +58,9 @@ fun AddAMessageRow(
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
canScheduleSend: Boolean = false
canScheduleSend: Boolean = false,
viewOnceAvailable: Boolean = false,
viewOnce: Boolean = false
) {
Row(
horizontalArrangement = Arrangement.Center,
@@ -70,24 +73,55 @@ fun AddAMessageRow(
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
.weight(1f)
.heightIn(min = 40.dp)
.clickable(enabled = enabled, onClickLabel = stringResource(R.string.AddAMessageRow__add_a_message), onClick = { onEvent(MediaEditScreenEvent.AddMessageClick()) }, role = Role.Button)
.then(
if (viewOnce) {
// A view-once send cannot carry a body, so the row becomes a static label rather than an entry point.
Modifier
} else {
Modifier.clickable(enabled = enabled, onClickLabel = stringResource(R.string.AddAMessageRow__add_a_message), onClick = { onEvent(MediaEditScreenEvent.AddMessageClick()) }, role = Role.Button)
}
)
) {
IconButtons.IconButton(
enabled = enabled,
onClick = { onEvent(MediaEditScreenEvent.AddMessageClick(startWithEmojiKeyboard = true)) }
) {
Icon(
painter = SignalIcons.Emoji.painter,
contentDescription = stringResource(R.string.AddAMessageRow__open_emoji_keyboard)
if (viewOnce) {
Text(
text = stringResource(R.string.AddAMessageRow__view_once_media),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
)
} else {
IconButtons.IconButton(
enabled = enabled,
onClick = { onEvent(MediaEditScreenEvent.AddMessageClick(startWithEmojiKeyboard = true)) }
) {
Icon(
painter = SignalIcons.Emoji.painter,
contentDescription = stringResource(R.string.AddAMessageRow__open_emoji_keyboard)
)
}
LocalAddAMessageRowTextField.current(
message ?: stringResource(R.string.AddAMessageRow__message),
Modifier
.weight(1f)
.padding(end = if (viewOnceAvailable) 0.dp else 16.dp)
)
}
LocalAddAMessageRowTextField.current(
message ?: stringResource(R.string.AddAMessageRow__message),
Modifier
.weight(1f)
.padding(end = 16.dp)
)
if (viewOnceAvailable) {
IconButtons.IconButton(
enabled = enabled,
onClick = { onEvent(MediaEditScreenEvent.ToggleViewOnce) }
) {
Icon(
painter = if (viewOnce) SignalIcons.ViewOnce.painter else SignalIcons.ViewOnceInfinite.painter,
contentDescription = stringResource(R.string.AddAMessageRow__toggle_view_once)
)
}
}
}
Box {
@@ -134,3 +168,30 @@ private fun AddAMessageRowPreview() {
)
}
}
@DayNightPreviews
@Composable
private fun AddAMessageRowViewOnceAvailablePreview() {
Previews.Preview {
AddAMessageRow(
message = null,
onEvent = {},
onNextClick = {},
viewOnceAvailable = true
)
}
}
@DayNightPreviews
@Composable
private fun AddAMessageRowViewOncePreview() {
Previews.Preview {
AddAMessageRow(
message = null,
onEvent = {},
onNextClick = {},
viewOnceAvailable = true,
viewOnce = true
)
}
}
@@ -317,6 +317,8 @@ internal fun MediaEditScreen(
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
canScheduleSend = !state.isStory,
viewOnceAvailable = state.isViewOnceAvailable,
viewOnce = state.isViewOnceEnabled,
message = state.message,
onEvent = onEvent,
onNextClick = { onEvent(MediaEditScreenEvent.NextClick) },
@@ -20,6 +20,7 @@ sealed interface MediaEditScreenEvent {
data object NavigateBack : MediaEditScreenEvent
data object NavigateToGallery : MediaEditScreenEvent
data object ToggleMediaQuality : MediaEditScreenEvent
data object ToggleViewOnce : MediaEditScreenEvent
data class BrushWidthChanged(val tool: BrushTool, val fraction: Float) : MediaEditScreenEvent
data object SaveMedia : MediaEditScreenEvent
data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent
@@ -106,8 +106,11 @@ internal fun MediaEditorToolbarSharedButtons(
)
}
MediaEditorToolbarButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) }
)
// Adding a second attachment would silently drop view-once, so the entry point goes away while it is on.
if (!state.isViewOnceEnabled) {
MediaEditorToolbarButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) }
)
}
}
@@ -28,6 +28,10 @@
<string name="AddAMessageRow__schedule_send">Schedule send</string>
<!-- Menu option that opens a picker for choosing the exact date and time to send at. -->
<string name="ScheduleSendMenu__pick_date_and_time">Pick Date &amp; Time</string>
<!-- Label shown in place of the add-a-message row when the media has been set to view once, indicating that no message can be attached. -->
<string name="AddAMessageRow__view_once_media">View once media</string>
<!-- Accessibility label for the button at the end of the add-a-message row that turns view-once mode on and off. -->
<string name="AddAMessageRow__toggle_view_once">Toggle view once</string>
<!-- Setting option that can be selected to default media to be sent as high quality by default -->
<string name="SentMediaQuality__high">High</string>
<!-- Setting option that can be selected to default media to be sent as standard quality by default -->
@@ -147,6 +151,10 @@
<string name="MediaSendViewModel__error_saving_media">Error while saving attachment to storage!</string>
<!-- Displayed when the media being edited cannot be saved because the storage permission was denied -->
<string name="MediaSendViewModel__unable_to_save_without_storage_permission">Unable to save to external storage without permissions</string>
<!-- Small notification presented to the user when they set their photo to view-once mode -->
<string name="MediaSendViewModel__photo_set_to_view_once">Photo set to view once</string>
<!-- Small notification presented to the user when they set their video to view-once mode -->
<string name="MediaSendViewModel__video_set_to_view_once">Video set to view once</string>
<!-- Message shown when saving the media being edited requires the storage permission, but that permission has been permanently denied by the user. -->
<string name="MediaSendViewModel__signal_needs_the_storage_permission">Signal needs the Storage permission in order to save to external storage, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\".</string>