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