Show story media previews in the destination picker on the V3 media send flow.

This commit is contained in:
Greyson Parrelli
2026-07-31 18:13:09 +00:00
committed by Alex Hart
parent 7e0bb9c5cb
commit b63b9ce71c
@@ -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<Uri> 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<ContactSearchData.Arbitrary> = if (hasPreviewableMedia) listOf(ContactSearchData.Arbitrary(PREVIEW_ITEM)) else emptyList()
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> = PreviewEntryMappingModel()
}
}
@Composable
override fun getAdditionalEntries(): MappingEntryProvider<Any> {
return rememberMappingEntryProvider {
entry<PreviewEntryMappingModel> {
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<PreviewEntryMappingModel> {
override fun areItemsTheSame(newItem: PreviewEntryMappingModel): Boolean = true
override fun areContentsTheSame(newItem: PreviewEntryMappingModel): Boolean = true
}
}