diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index 9d497c4427..00220a5a3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -26,6 +26,7 @@ import org.signal.core.ui.view.Stub import org.signal.core.util.ByteLimitInputFilter import org.signal.core.util.EditTextUtil import org.signal.core.util.getParcelableCompat +import org.signal.mediasend.HudCommand import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout @@ -338,6 +339,10 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a const val RESULT_INCREMENT_VIEW_ONCE_STATE = "AddMessageViewModel_IncrementViewOnceState" const val RESULT_MESSAGE = "AddMessageViewModel__Message" + fun show(fragmentManager: FragmentManager, addAMessageDialog: HudCommand.ShowAddAMessageDialog, destination: RecipientId?) { + return show(fragmentManager, addAMessageDialog.message, addAMessageDialog.startWithEmojiKeyboard, addAMessageDialog.isViewOnceAvailable, destination) + } + fun show(fragmentManager: FragmentManager, initialText: CharSequence?, startWithEmojiKeyboard: Boolean, isViewOnceAvailable: Boolean, destination: RecipientId?) { AddMessageDialogFragment().apply { arguments = Bundle().apply { 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 d65e9d613f..a10a078bde 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 @@ -9,11 +9,18 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.compose.AndroidFragment +import org.signal.mediasend.HudCommand import org.signal.mediasend.MediaSendActivityContract import org.signal.mediasend.MediaSendScreen +import org.signal.mediasend.edit.LocalAddAMessageRowTextField import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment +import org.thoughtcrime.securesms.recipients.RecipientId /** * Encapsulates the media send flow for v3. @@ -26,15 +33,40 @@ class MediaSendV3Activity : PassphraseRequiredActivity() { val contractArgs = MediaSendActivityContract.Args.fromIntent(intent) setContent { - MediaSendScreen( - contractArgs = contractArgs, - sendSlot = { - AndroidFragment( - clazz = MediaSendV3ForwardFragment::class.java, - modifier = Modifier.fillMaxSize() + CompositionLocalProvider( + LocalAddAMessageRowTextField provides { message, modifier -> + AndroidView( + factory = { EmojiTextView(it) }, + update = { view -> + view.text = message + }, + modifier = modifier ) } - ) + ) { + MediaSendScreen( + contractArgs = contractArgs, + sendSlot = { + AndroidFragment( + clazz = MediaSendV3ForwardFragment::class.java, + modifier = Modifier.fillMaxSize() + ) + }, + onExternalHudCommand = { + when (it) { + is HudCommand.ShowAddAMessageDialog -> { + AddMessageDialogFragment.show( + fragmentManager = supportFragmentManager, + addAMessageDialog = it, + destination = contractArgs.recipientId?.let { + RecipientId.from(it.id) + } + ) + } + } + } + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java index a894f1bbad..899e0e90d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.video.videoconverter; import android.content.Context; @@ -17,9 +22,7 @@ import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.RequiresApi; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ViewUtil; +import org.signal.core.util.DimensionUnit; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -27,12 +30,10 @@ import java.util.concurrent.TimeUnit; @RequiresApi(api = 23) public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView { - private static final String TAG = Log.tag(VideoThumbnailsRangeSelectorView.class); - private static final long MINIMUM_SELECTABLE_RANGE = TimeUnit.MILLISECONDS.toMicros(500); private static final int ANIMATION_DURATION_MS = 100; - private static final float THUMB_RECT_CORNER_RADIUS = ViewUtil.dpToPx(4); - private static final float ACTIVE_REGION_CORNER_RADIUS = ViewUtil.dpToPx(8); + private static final float THUMB_RECT_CORNER_RADIUS = DimensionUnit.DP.toPixels(4); + private static final float ACTIVE_REGION_CORNER_RADIUS = DimensionUnit.DP.toPixels(8); private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint paintGrey = new Paint(Paint.ANTI_ALIAS_FLAG); @@ -85,14 +86,14 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView private void init(final @Nullable AttributeSet attrs) { if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.VideoThumbnailsRangeSelectorView, 0, 0); + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView, 0, 0); try { - thumbSizePixels = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbWidth, 1); - thumbColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbColor, 0xffff0000); - thumbTouchRadius = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbTouchRadius, 50); - thumbHintTextSize = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextSize, 0); - thumbHintTextColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextColor, 0xffff0000); - thumbHintBackgroundColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintBackgroundColor, 0xff00ff00); + thumbSizePixels = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbWidth, 1); + thumbColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbColor, 0xffff0000); + thumbTouchRadius = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbTouchRadius, 50); + thumbHintTextSize = typedArray.getDimensionPixelSize(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextSize, 0); + thumbHintTextColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextColor, 0xffff0000); + thumbHintBackgroundColor = typedArray.getColor(org.signal.mediasend.R.styleable.VideoThumbnailsRangeSelectorView_thumbHintBackgroundColor, 0xff00ff00); } finally { typedArray.recycle(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java index a4ba756174..56cf9ffdf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java @@ -16,9 +16,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import org.signal.core.util.DimensionUnit; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; -import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.video.interfaces.MediaInput; import java.io.IOException; @@ -31,7 +31,7 @@ import java.util.List; abstract public class VideoThumbnailsView extends View { private static final String TAG = Log.tag(VideoThumbnailsView.class); - private static final int CORNER_RADIUS = ViewUtil.dpToPx(8); + private static final int CORNER_RADIUS = (int) DimensionUnit.DP.toPixels(8); protected Uri currentUri; diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 2b22368e2b..34e7fca11b 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -273,16 +273,6 @@ - - - - - - - - - - diff --git a/feature/media-send/build.gradle.kts b/feature/media-send/build.gradle.kts index 9cf26d89ea..03f7dd1264 100644 --- a/feature/media-send/build.gradle.kts +++ b/feature/media-send/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(project(":core:models")) implementation(project(":lib:image-editor")) implementation(project(":lib:glide")) + implementation(project(":lib:video")) // Compose BOM platform(libs.androidx.compose.bom).let { composeBom -> diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt deleted file mode 100644 index 412b8e6ee0..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediasend - -import android.net.Uri -import org.signal.core.models.media.Media -import org.signal.mediasend.edit.MediaEditScreenCallback -import org.signal.mediasend.select.MediaSelectScreenCallback - -/** - * Interface for communicating user intent back up to the view-model. - */ -interface MediaSendCallback : MediaEditScreenCallback, MediaSelectScreenCallback { - - /** Called when the user navigates to a different position. */ - fun onPageChanged(position: Int) {} - - /** Called when the user edits video trim data. */ - fun onVideoEdited(uri: Uri, isEdited: Boolean) {} - - object Empty : MediaSendCallback, MediaEditScreenCallback by MediaEditScreenCallback.Empty, MediaSelectScreenCallback by MediaSelectScreenCallback.Empty { - override fun setFocusedMedia(media: Media) = Unit - } -} - -/** - * Commands sent from the ViewModel to the UI layer (HUD). - * - * These are one-shot events that don't belong in persistent state. - */ -sealed interface HudCommand { - /** Start camera capture flow. */ - data object StartCamera : HudCommand - - /** Open the media selector/gallery. */ - data object OpenGallery : HudCommand - - /** Resume previously paused video. */ - data object ResumeVideo : HudCommand - - /** Show a transient error message. */ - data class ShowError(val message: String) : HudCommand -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt new file mode 100644 index 0000000000..b0abb81bec --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +import org.signal.mediasend.edit.MediaEditScreenEvent +import org.signal.mediasend.select.MediaSelectScreenEvent + +interface MediaSendEventHandler { + fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) + fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) + + object Empty : MediaSendEventHandler { + override fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) = Unit + override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) = Unit + } +} + +/** + * Commands sent from the ViewModel to the UI layer (HUD). + * + * These are one-shot events that don't belong in persistent state. + */ +sealed interface HudCommand { + + /** Show the dialog to allow the user to add a message */ + data class ShowAddAMessageDialog( + val message: String, + val startWithEmojiKeyboard: Boolean, + val isViewOnceAvailable: Boolean + ) : HudCommand +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt index de4a8968b8..454db38330 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt @@ -33,7 +33,7 @@ import org.signal.mediasend.select.MediaSelectScreen fun MediaSendNavDisplay( stateFlow: StateFlow, backStack: NavBackStack, - callback: MediaSendCallback, + eventHandler: MediaSendEventHandler, modifier: Modifier = Modifier, cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, @@ -58,7 +58,7 @@ fun MediaSendNavDisplay( MediaSelectScreen( state = state, backStack = backStack, - callback = callback + onEvent = eventHandler::onMediaSelectScreenEvent ) } @@ -68,7 +68,7 @@ fun MediaSendNavDisplay( state = state, backStack = backStack, videoEditorSlot = videoEditorSlot, - callback = callback + onEvent = eventHandler::onMediaEditScreenEvent ) } @@ -90,7 +90,7 @@ private fun MediaSendNavDisplayPreview() { MediaSendNavDisplay( stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)), backStack = rememberNavBackStack(MediaSendNavKey.Edit), - callback = MediaSendCallback.Empty, + eventHandler = MediaSendEventHandler.Empty, cameraSlot = { BoxWithText("Camera Slot") }, textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") }, videoEditorSlot = { BoxWithText("Video Editor Slot") }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index ddf28fa512..160082ff75 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -9,6 +9,7 @@ import androidx.activity.compose.LocalActivity import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -25,7 +26,8 @@ fun MediaSendScreen( cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, videoEditorSlot: @Composable () -> Unit = {}, - sendSlot: @Composable (MediaSendState) -> Unit = {} + sendSlot: @Composable (MediaSendState) -> Unit = {}, + onExternalHudCommand: (HudCommand) -> Unit = {} ) { val viewModel = viewModel(factory = MediaSendViewModel.Factory(args = contractArgs)) @@ -34,13 +36,19 @@ fun MediaSendScreen( if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select ) + LaunchedEffect(viewModel) { + viewModel.hudCommands.collect { command -> + onExternalHudCommand(command) + } + } + SignalTheme { CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides LocalActivity.current as NavigationEventDispatcherOwner) { Surface { MediaSendNavDisplay( stateFlow = viewModel.state, backStack = backStack, - callback = viewModel, + eventHandler = viewModel, modifier = modifier, cameraSlot = cameraSlot, textStoryEditorSlot = textStoryEditorSlot, 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 183dfafde9..928fa75e04 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 @@ -37,7 +37,9 @@ import org.signal.core.util.StringUtil import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.UriGlideRenderer +import org.signal.mediasend.edit.MediaEditScreenEvent import org.signal.mediasend.preupload.PreUploadController +import org.signal.mediasend.select.MediaSelectScreenEvent import java.util.Collections import kotlin.time.Duration.Companion.milliseconds @@ -52,7 +54,7 @@ class MediaSendViewModel( private val repository: MediaSendRepository, private val preUploadController: PreUploadController, isMeteredFlow: Flow -) : ViewModel(), MediaSendCallback { +) : ViewModel(), MediaSendEventHandler { private val args: MediaSendActivityContract.Args = savedStateHandle[KEY_ARGS] ?: throw IllegalStateException("MediaSendViewModel requires args in SavedStateHandle. Use Factory to create.") @@ -148,7 +150,32 @@ class MediaSendViewModel( } } - override fun onFolderClick(mediaFolder: MediaFolder?) { + override fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) { + when (mediaSelectScreenEvent) { + is MediaSelectScreenEvent.FolderClick -> onFolderClick(mediaSelectScreenEvent.mediaFolder) + is MediaSelectScreenEvent.MediaClick -> onMediaClick(mediaSelectScreenEvent.media) + is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media) + } + } + + override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) { + when (mediaEditScreenEvent) { + is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media) + is MediaEditScreenEvent.AddMessageClick -> { + val snapshot: MediaSendState = state.value + + sendHudCommand( + HudCommand.ShowAddAMessageDialog( + message = snapshot.message ?: "", + startWithEmojiKeyboard = mediaEditScreenEvent.startWithEmojiKeyboard, + isViewOnceAvailable = snapshot.selectedMedia.size == 1 && !snapshot.isStory && !ContentTypeUtil.isDocumentType(snapshot.focusedMedia?.contentType) + ) + ) + } + } + } + + private fun onFolderClick(mediaFolder: MediaFolder?) { viewModelScope.launch { if (mediaFolder != null) { val media = repository.getMedia(mediaFolder.bucketId) @@ -159,7 +186,7 @@ class MediaSendViewModel( } } - override fun onMediaClick(media: Media) { + private fun onMediaClick(media: Media) { if (media.uri in internalState.value.selectedMedia.map { it.uri }) { removeMedia(media) } else { @@ -167,6 +194,12 @@ class MediaSendViewModel( } } + private fun sendHudCommand(hudCommand: HudCommand) { + viewModelScope.launch { + hudCommandChannel.send(hudCommand) + } + } + /** * Adds [media] to the selection, preserving insertion order and uniqueness by equality. * @@ -399,7 +432,7 @@ class MediaSendViewModel( /** * Notifies the view-model that a video's trim/duration has been edited. */ - override fun onVideoEdited(uri: Uri, isEdited: Boolean) { + private fun onVideoEdited(uri: Uri, isEdited: Boolean) { if (!isEdited) return if (!editedVideoUris.add(uri)) return @@ -474,11 +507,11 @@ class MediaSendViewModel( //region Page/Focus Management - override fun setFocusedMedia(media: Media) { + private fun setFocusedMedia(media: Media) { updateState { copy(focusedMedia = media) } } - override fun onPageChanged(position: Int) { + private fun onPageChanged(position: Int) { val snapshot = state.value val focused = if (position >= snapshot.selectedMedia.size) null else snapshot.selectedMedia[position] updateState { copy(focusedMedia = focused) } @@ -572,7 +605,7 @@ class MediaSendViewModel( updateState { copy(message = text) } } - override fun onMessageChange(message: String) { + private fun onMessageChange(message: String) { setMessage(message) } 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 438fd745d7..684718ac51 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 @@ -5,8 +5,8 @@ package org.signal.mediasend.edit -import androidx.compose.animation.Crossfade import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.heightIn @@ -14,27 +14,43 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.IconButtons import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons -import org.signal.core.util.isNotNullOrBlank +import org.signal.mediasend.R + +/** + * Because we need to be able to support stuff like mentions, styled text, and custom emoji, we need to allow + * the users of this feature to inject their own text-field. + */ +val LocalAddAMessageRowTextField = compositionLocalOf<@Composable (String, Modifier) -> Unit> { + { message, modifier -> + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + ) + } +} @Composable fun AddAMessageRow( message: String?, - callback: AddAMessageRowCallback, + onEvent: (MediaEditScreenEvent) -> Unit, onNextClick: () -> Unit, - modifier: Modifier = Modifier, - onEmojiKeyboardClick: () -> Unit = {} + modifier: Modifier = Modifier ) { Row( horizontalArrangement = Arrangement.Center, @@ -47,32 +63,23 @@ fun AddAMessageRow( .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50)) .weight(1f) .heightIn(min = 40.dp) + .clickable(onClickLabel = stringResource(R.string.AddAMessageRow__add_a_message), onClick = { onEvent(MediaEditScreenEvent.AddMessageClick()) }, role = Role.Button) ) { IconButtons.IconButton( - onClick = onEmojiKeyboardClick + onClick = { onEvent(MediaEditScreenEvent.AddMessageClick(startWithEmojiKeyboard = true)) } ) { Icon( painter = SignalIcons.Emoji.painter, - contentDescription = "Open emoji keyboard" + contentDescription = stringResource(R.string.AddAMessageRow__open_emoji_keyboard) ) } - Crossfade( - targetState = message.isNotNullOrBlank(), - modifier = Modifier.weight(1f) - ) { isNotEmpty -> - if (isNotEmpty) { - BasicTextField( - value = message ?: "", - onValueChange = callback::onMessageChange - ) - } else - Text( - text = "Message", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + LocalAddAMessageRowTextField.current( + message ?: stringResource(R.string.AddAMessageRow__message), + Modifier + .weight(1f) + .padding(end = 16.dp) + ) } IconButtons.IconButton( @@ -86,7 +93,7 @@ fun AddAMessageRow( ) { Icon( painter = SignalIcons.ArrowEnd.painter, - contentDescription = "Open emoji keyboard", + contentDescription = stringResource(R.string.AddAMessageRow__next), modifier = Modifier .size(40.dp) .padding(8.dp) @@ -101,16 +108,8 @@ private fun AddAMessageRowPreview() { Previews.Preview { AddAMessageRow( message = null, - callback = AddAMessageRowCallback.Empty, + onEvent = {}, onNextClick = {} ) } } - -interface AddAMessageRowCallback { - fun onMessageChange(message: String) - - object Empty : AddAMessageRowCallback { - override fun onMessageChange(message: String) = Unit - } -} 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 363f50e2e2..81db72224f 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 @@ -29,7 +29,6 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack import kotlinx.coroutines.launch -import org.signal.core.models.media.Media import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Previews @@ -43,7 +42,7 @@ import org.signal.mediasend.goToSend @Composable fun MediaEditScreen( state: MediaSendState, - callback: MediaEditScreenCallback, + onEvent: (MediaEditScreenEvent) -> Unit, backStack: NavBackStack, videoEditorSlot: @Composable () -> Unit = {} ) { @@ -112,7 +111,9 @@ fun MediaEditScreen( ThumbnailRow( selectedMedia = state.selectedMedia, pagerState = pagerState, - onFocusedMediaChange = callback::setFocusedMedia, + onFocusedMediaChange = { + onEvent(MediaEditScreenEvent.FocusedMediaChanged(it)) + }, onThumbnailClick = { index -> scope.launch { pagerState.animateScrollToPage(index) @@ -141,7 +142,7 @@ fun MediaEditScreen( if (currentController?.isUserInEdit != true) { AddAMessageRow( message = state.message, - callback = callback, + onEvent = onEvent, onNextClick = { backStack.goToSend() }, modifier = Modifier .widthIn(max = 624.dp) @@ -177,7 +178,7 @@ private fun MediaEditScreenPreview() { selectedMedia.first().uri to EditorState.Image(EditorModel.create(0)) ) ), - callback = MediaEditScreenCallback.Empty, + onEvent = {}, backStack = rememberNavBackStack(MediaSendNavKey.Edit), videoEditorSlot = { Box( @@ -189,11 +190,3 @@ private fun MediaEditScreenPreview() { ) } } - -interface MediaEditScreenCallback : AddAMessageRowCallback { - fun setFocusedMedia(media: Media) - - object Empty : MediaEditScreenCallback, AddAMessageRowCallback by AddAMessageRowCallback.Empty { - override fun setFocusedMedia(media: Media) = Unit - } -} 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 new file mode 100644 index 0000000000..cc9bb01ff1 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import org.signal.core.models.media.Media + +sealed interface MediaEditScreenEvent { + data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent + data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt index 6bc9f29e82..359f586de1 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt @@ -84,7 +84,7 @@ import org.signal.mediasend.pop internal fun MediaSelectScreen( state: MediaSendState, backStack: NavBackStack, - callback: MediaSelectScreenCallback + onEvent: (MediaSelectScreenEvent) -> Unit ) { val gridConfiguration = rememberGridConfiguration(state.selectedMediaFolder == null) @@ -93,7 +93,7 @@ internal fun MediaSelectScreen( navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24), onNavigationClick = { if (state.selectedMediaFolder != null) { - callback.onFolderClick(null) + onEvent(MediaSelectScreenEvent.FolderClick(null)) } else { backStack.pop() } @@ -114,11 +114,11 @@ internal fun MediaSelectScreen( ) { if (state.selectedMediaFolder == null) { items(state.mediaFolders, key = { it.bucketId }) { - MediaFolderTile(it, callback) + MediaFolderTile(it, onEvent) } } else { items(state.selectedMediaFolderItems, key = { it.uri }) { media -> - MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, callback = callback) + MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, onEvent = onEvent) } } } @@ -148,7 +148,7 @@ internal fun MediaSelectScreen( ) { items(state.selectedMedia, key = { it.uri }) { media -> MediaThumbnail(media, modifier = Modifier.animateItem()) { - callback.setFocusedMedia(media) + onEvent(MediaSelectScreenEvent.SetFocusedMedia(media)) backStack.goToEdit() } } @@ -243,13 +243,13 @@ private fun WindowSizeClass.forWidthBreakpoint( @Composable private fun MediaFolderTile( mediaFolder: MediaFolder, - callback: MediaSelectScreenCallback + onEvent: (MediaSelectScreenEvent) -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .clickable( - onClick = { callback.onFolderClick(mediaFolder) }, + onClick = { onEvent(MediaSelectScreenEvent.FolderClick(mediaFolder)) }, onClickLabel = mediaFolder.title, role = Role.Button ), @@ -289,7 +289,7 @@ private fun MediaFolderTile( private fun MediaTile( media: Media, selectionIndex: Int, - callback: MediaSelectScreenCallback + onEvent: (MediaSelectScreenEvent) -> Unit ) { val scale by animateFloatAsState( targetValue = if (selectionIndex >= 0) { @@ -307,7 +307,7 @@ private fun MediaTile( modifier = Modifier .background(color = MaterialTheme.colorScheme.surfaceVariant) .clickable( - onClick = { callback.onMediaClick(media) }, + onClick = { onEvent(MediaSelectScreenEvent.MediaClick(media)) }, onClickLabel = media.fileName, role = Role.Button ) @@ -316,7 +316,8 @@ private fun MediaTile( Box( modifier = Modifier .scale(scale) - .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip)).fillMaxWidth() + .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip)) + .fillMaxWidth() .aspectRatio(1f) ) } else { @@ -410,7 +411,7 @@ private fun MediaSelectScreenFolderPreview() { mediaFolders = rememberPreviewMediaFolders(20) ), backStack = rememberNavBackStack(MediaSendNavKey.Edit), - callback = MediaSelectScreenCallback.Empty + onEvent = {} ) } } @@ -421,17 +422,6 @@ private fun MediaSelectScreenMediaPreview() { val folders = rememberPreviewMediaFolders(20) val media = rememberPreviewMedia(100) val selectedMedia: MutableList = remember { mutableStateListOf() } - val callback = remember { - object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty { - override fun onMediaClick(media: Media) { - if (media in selectedMedia) { - selectedMedia.remove(media) - } else { - selectedMedia.add(media) - } - } - } - } Previews.Preview { MediaSelectScreen( @@ -442,7 +432,15 @@ private fun MediaSelectScreenMediaPreview() { selectedMedia = selectedMedia ), backStack = rememberNavBackStack(MediaSendNavKey.Edit), - callback = callback + onEvent = { + if (it is MediaSelectScreenEvent.MediaClick) { + if (it.media in selectedMedia) { + selectedMedia.remove(it.media) + } else { + selectedMedia.add(it.media) + } + } + } ) } } @@ -454,7 +452,7 @@ private fun MediaFolderTilePreview() { Box(modifier = Modifier.width(174.dp)) { MediaFolderTile( mediaFolder = rememberPreviewMediaFolders(1).first(), - callback = MediaSelectScreenCallback.Empty + onEvent = {} ) } } @@ -467,7 +465,7 @@ private fun MediaTilePreview() { MediaTile( media = rememberPreviewMedia(1).first(), selectionIndex = -1, - callback = MediaSelectScreenCallback.Empty + onEvent = {} ) } } @@ -481,8 +479,8 @@ private fun MediaTileSelectedPreview() { MediaTile( media = rememberPreviewMedia(1).first(), selectionIndex = if (isSelected) 0 else -1, - callback = object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty { - override fun onMediaClick(media: Media) { + onEvent = { + if (it is MediaSelectScreenEvent.MediaClick) { isSelected = !isSelected } } @@ -525,15 +523,3 @@ private data class GridConfiguration( val bottomBarHorizontalPadding: Dp, val bottomBarAlignment: Alignment.Horizontal ) - -interface MediaSelectScreenCallback { - fun onFolderClick(mediaFolder: MediaFolder?) - fun onMediaClick(media: Media) - fun setFocusedMedia(media: Media) - - object Empty : MediaSelectScreenCallback { - override fun onFolderClick(mediaFolder: MediaFolder?) {} - override fun onMediaClick(media: Media) {} - override fun setFocusedMedia(media: Media) {} - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt new file mode 100644 index 0000000000..13d5b70efb --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.select + +import org.signal.core.models.media.Media +import org.signal.core.models.media.MediaFolder + +sealed interface MediaSelectScreenEvent { + data class FolderClick(val mediaFolder: MediaFolder?) : MediaSelectScreenEvent + data class MediaClick(val media: Media) : MediaSelectScreenEvent + data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent +} diff --git a/feature/media-send/src/main/res/values/attrs.xml b/feature/media-send/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..131b80cbfd --- /dev/null +++ b/feature/media-send/src/main/res/values/attrs.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index 3927cde1f7..f497b57832 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -6,4 +6,12 @@ You\'ll lose any changes you\'ve made to this photo. Discard + + Add a message + + Open emoji keyboard + + Message + + Next