From b63b9ce71c415d90fbaa5476dac8f27049bb5f6e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 31 Jul 2026 18:13:09 +0000 Subject: [PATCH] Show story media previews in the destination picker on the V3 media send flow. --- .../v3/MediaSendV3ForwardFragment.kt | 159 +++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt index c559a9664d..32733871d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt @@ -5,22 +5,51 @@ package org.thoughtcrime.securesms.mediasend.v3 +import android.net.Uri import android.os.Bundle import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.ContentTypeUtil import org.signal.core.util.getParcelableArrayListCompat +import org.signal.core.util.logging.Log +import org.signal.mediasend.EditorState import org.signal.mediasend.MediaRecipientId import org.signal.mediasend.MediaSendActivityContract import org.signal.mediasend.MediaSendRecipient +import org.signal.mediasend.MediaSendState import org.signal.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository +import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration +import org.thoughtcrime.securesms.contacts.paged.ContactSearchData import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.ContactSearchState import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs +import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider +import org.thoughtcrime.securesms.mediasend.v2.stories.StoryMediaPreviews +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider +import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider import org.signal.core.ui.R as CoreUiR /** @@ -29,18 +58,51 @@ import org.signal.core.ui.R as CoreUiR * * Implements the callback interface and uses the shared [MediaSendViewModel] to drive * the send flow forward. + * + * For story sends this also acts as the [SearchConfigurationProvider], swapping the default + * destination list for the stories-only list and prepending a preview of the media being posted. */ -class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback { +class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider { + + companion object { + private val TAG = Log.tag(MediaSendV3ForwardFragment::class) + + private const val PREVIEW_ITEM = "preview_item" + + /** The number of selected media items rendered into the destination picker preview. */ + private const val PREVIEW_COUNT = 2 + + /** + * Fixed height of the preview row, so the list does not shift when the asynchronously rendered + * previews arrive. Matches the tallest thumbnail in [StoryMediaPreviews] plus its vertical padding. + */ + private val PREVIEW_HEIGHT = 260.dp + } private val viewModel: MediaSendViewModel by activityViewModels { MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent)) } + /** + * The rendered story previews. Backed by compose state because rendering the image editor models happens off the main + * thread, after the picker has already been composed. + */ + private var storyPreviews: List by mutableStateOf(emptyList()) + + private val isStory: Boolean get() = viewModel.state.value.isStory + + private val hasPreviewableMedia: Boolean get() = viewModel.state.value.selectedMedia.isNotEmpty() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val state = viewModel.state.value + + if (state.isStory && storyPreviews.isEmpty()) { + renderStoryPreviews(state) + } + if (savedInstanceState == null) { - val state = viewModel.state.value val forwardFragment = MultiselectForwardFragment.create( MultiselectForwardFragmentArgs( title = R.string.MediaReviewFragment__send_to, @@ -84,4 +146,97 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? { return viewModel.getStorySendRequirements().toAppSendRequirements() } + + override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration? { + if (!isStory) { + return null + } + + return ContactSearchConfiguration.build { + query = contactSearchState.query + + addSection( + ContactSearchConfiguration.Section.Arbitrary(setOf(PREVIEW_ITEM)) + ) + + addSection( + ContactSearchConfiguration.Section.Stories( + groupStories = contactSearchState.groupStories, + includeHeader = true, + headerAction = Stories.getHeaderAction(fragmentManager) + ) + ) + } + } + + override fun getArbitraryRepository(): ArbitraryRepository? { + if (!isStory) { + return null + } + + return object : ArbitraryRepository { + /** + * Reported as present as soon as there is media to preview, rather than once the previews have finished rendering. + * The section size is only sampled while the paged data source is being built, so a size that flipped from zero to + * one later on would never be picked up. + */ + override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int = if (hasPreviewableMedia) 1 else 0 + + override fun getData( + section: ContactSearchConfiguration.Section.Arbitrary, + query: String?, + startIndex: Int, + endIndex: Int, + totalSearchSize: Int + ): List = if (hasPreviewableMedia) listOf(ContactSearchData.Arbitrary(PREVIEW_ITEM)) else emptyList() + + override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> = PreviewEntryMappingModel() + } + } + + @Composable + override fun getAdditionalEntries(): MappingEntryProvider { + return rememberMappingEntryProvider { + entry { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PREVIEW_HEIGHT) + ) { + StoryMediaPreviews(previews = storyPreviews) + } + } + } + } + + /** + * Renders the first [PREVIEW_COUNT] selected media into single-session blobs so the picker shows what is actually + * being posted, edits included. Items that aren't routed through the image editor are previewed as-is. + */ + private fun renderStoryPreviews(state: MediaSendState) { + val context = requireContext().applicationContext + + viewLifecycleOwner.lifecycleScope.launch { + storyPreviews = withContext(Dispatchers.IO) { + state.selectedMedia.take(PREVIEW_COUNT).map { media -> + val editorState = state.editorStateMap[media.uri] + if (ContentTypeUtil.isImageType(media.contentType) && editorState is EditorState.Image) { + try { + ImageEditorFragment.renderToSingleSessionBlob(context, editorState.model) + } catch (e: Exception) { + Log.w(TAG, "Failed to render story preview. Falling back to the original media.", e) + media.uri + } + } else { + media.uri + } + } + } + } + } + + private class PreviewEntryMappingModel : MappingModel { + override fun areItemsTheSame(newItem: PreviewEntryMappingModel): Boolean = true + override fun areContentsTheSame(newItem: PreviewEntryMappingModel): Boolean = true + } }