diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index a28b727ba4..be8d41d662 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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))); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index f92b2e15b7..86d3fa2405 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -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) { 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 3aa6cbe251..7a9cfc39ad 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 @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt index c00199aadc..c559a9664d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt @@ -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 ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java index 234b4d2f34..00c6ce8895 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java @@ -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(""); diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 6a1416fb37..0380a7de05 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -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)) } diff --git a/app/src/main/res/drawable/symbol_view_once_24.xml b/core/ui/src/main/res/drawable/symbol_view_once_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_view_once_24.xml rename to core/ui/src/main/res/drawable/symbol_view_once_24.xml diff --git a/app/src/main/res/drawable/symbol_view_once_infinite_24.xml b/core/ui/src/main/res/drawable/symbol_view_once_infinite_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_view_once_infinite_24.xml rename to core/ui/src/main/res/drawable/symbol_view_once_infinite_24.xml diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index 9119d78ac4..f2c81400e0 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -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() } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 59a9863409..9acb9dc436 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt index a1741fe003..f17d5529e3 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt @@ -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 + ) + } +} 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 036481d3d5..682381f8ef 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 @@ -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) }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index cf1f994261..fe4b64d3d4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt index 5deaff46ae..c504260282 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt @@ -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) } + ) + } } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index 6af09fa7fb..9b22359f90 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -28,6 +28,10 @@ Schedule send Pick Date & Time + + View once media + + Toggle view once High @@ -147,6 +151,10 @@ Error while saving attachment to storage! Unable to save to external storage without permissions + + Photo set to view once + + Video set to view once 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\".