Utilize events instead of callbacks in MediaSend feature module.

This commit is contained in:
Alex Hart
2026-06-15 14:54:07 -03:00
committed by Greyson Parrelli
parent d22a2c0a50
commit 933b799266
18 changed files with 266 additions and 178 deletions
@@ -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 {
@@ -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)
}
)
}
}
}
)
}
}
}
}
@@ -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();
}
@@ -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;
-10
View File
@@ -273,16 +273,6 @@
<attr name="rcv_outgoing" format="boolean" />
</declare-styleable>
<declare-styleable name="VideoThumbnailsRangeSelectorView">
<attr name="thumbWidth" format="dimension" />
<attr name="thumbColorEdited" format="color" />
<attr name="thumbColor" format="color" />
<attr name="thumbTouchRadius" format="dimension" />
<attr name="thumbHintTextSize" format="dimension" />
<attr name="thumbHintTextColor" format="color" />
<attr name="thumbHintBackgroundColor" format="color" />
</declare-styleable>
<declare-styleable name="VideoPlayer">
<attr name="playerLayoutId" format="dimension" />
</declare-styleable>
+1
View File
@@ -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 ->
@@ -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
}
@@ -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
}
@@ -33,7 +33,7 @@ import org.signal.mediasend.select.MediaSelectScreen
fun MediaSendNavDisplay(
stateFlow: StateFlow<MediaSendState>,
backStack: NavBackStack<NavKey>,
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") },
@@ -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<MediaSendViewModel>(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,
@@ -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<Boolean>
) : 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)
}
@@ -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
}
}
@@ -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<NavKey>,
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
}
}
@@ -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
}
@@ -84,7 +84,7 @@ import org.signal.mediasend.pop
internal fun MediaSelectScreen(
state: MediaSendState,
backStack: NavBackStack<NavKey>,
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 <T> 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<Media> = 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) {}
}
}
@@ -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
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<declare-styleable name="VideoThumbnailsRangeSelectorView">
<attr name="thumbWidth" format="dimension" />
<attr name="thumbColorEdited" format="color" />
<attr name="thumbColor" format="color" />
<attr name="thumbTouchRadius" format="dimension" />
<attr name="thumbHintTextSize" format="dimension" />
<attr name="thumbHintTextColor" format="color" />
<attr name="thumbHintBackgroundColor" format="color" />
</declare-styleable>
</resources>
@@ -6,4 +6,12 @@
<string name="MediaSendDialogs__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
<!-- Confirmation button that discards the user\'s image edits. -->
<string name="MediaSendDialogs__discard">Discard</string>
<!-- Accessibility label for the row that lets the user add a text message to accompany the media being sent. -->
<string name="AddAMessageRow__add_a_message">Add a message</string>
<!-- Accessibility description for the button that opens the emoji keyboard. -->
<string name="AddAMessageRow__open_emoji_keyboard">Open emoji keyboard</string>
<!-- Placeholder text shown in the add-a-message row before the user has entered any message text. -->
<string name="AddAMessageRow__message">Message</string>
<!-- Accessibility description for the button that advances to the next step in the media send flow. -->
<string name="AddAMessageRow__next">Next</string>
</resources>