mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-29 18:45:58 +01:00
Utilize events instead of callbacks in MediaSend feature module.
This commit is contained in:
committed by
Greyson Parrelli
parent
d22a2c0a50
commit
933b799266
+5
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-14
@@ -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();
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user