mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add text story support.
This commit is contained in:
@@ -481,7 +481,7 @@
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
|
||||
@@ -11,15 +11,16 @@ import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.signal.mediasend.MediaSendRecipient
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Activity
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
/**
|
||||
* Single entry point for launching the media send flow.
|
||||
@@ -27,13 +28,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
* Callers describe the flow they want, and this decides whether to launch the v2 [MediaSelectionActivity] or the
|
||||
* v3 [MediaSendV3Activity], translating the request into that implementation's arguments.
|
||||
*
|
||||
* Flows v3 cannot express yet -- quick restore, text stories, and shares with pre-selected recipients -- always
|
||||
* launch v2.
|
||||
* Every flow is expressible by both implementations, so the choice is purely [useV3].
|
||||
*/
|
||||
object MediaSendLauncher {
|
||||
|
||||
private val TAG = Log.tag(MediaSendLauncher::class)
|
||||
|
||||
private val useV3: Boolean
|
||||
get() = SignalStore.internal.useNewMediaActivity
|
||||
|
||||
@@ -81,11 +79,18 @@ object MediaSendLauncher {
|
||||
}
|
||||
|
||||
fun cameraForQuickRestore(context: Context): Intent {
|
||||
if (useV3) {
|
||||
Log.i(TAG, "Quick restore is unsupported by v3. Falling back to v2.")
|
||||
return if (useV3) {
|
||||
v3Intent(
|
||||
context,
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = true,
|
||||
mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
|
||||
isForQuickRestore = true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
MediaSelectionActivity.cameraForQuickRestore(context)
|
||||
}
|
||||
|
||||
return MediaSelectionActivity.cameraForQuickRestore(context)
|
||||
}
|
||||
|
||||
fun addToGroupStory(context: Context, recipientId: RecipientId): Intent {
|
||||
@@ -177,11 +182,21 @@ object MediaSendLauncher {
|
||||
message: CharSequence?,
|
||||
asTextStory: Boolean
|
||||
): Intent {
|
||||
if (useV3) {
|
||||
Log.i(TAG, "Sharing with pre-selected recipients is unsupported by v3. Falling back to v2.")
|
||||
return if (useV3) {
|
||||
v3Intent(
|
||||
context,
|
||||
MediaSendActivityContract.Args(
|
||||
mode = MediaSendActivityContract.Mode.MultiRecipient,
|
||||
additionalRecipients = recipientSearchKeys.map { MediaSendRecipient(it.recipientId.toMediaRecipientId(), it.isStory) },
|
||||
initialMedia = media,
|
||||
initialMessage = message,
|
||||
isStory = recipientSearchKeys.any { it.isStory },
|
||||
asTextStory = asTextStory
|
||||
)
|
||||
)
|
||||
} else {
|
||||
MediaSelectionActivity.share(context, messageSendType, media, recipientSearchKeys, message, asTextStory)
|
||||
}
|
||||
|
||||
return MediaSelectionActivity.share(context, messageSendType, media, recipientSearchKeys, message, asTextStory)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +215,8 @@ object MediaSendLauncher {
|
||||
}
|
||||
|
||||
private fun v3Intent(context: Context, args: MediaSendActivityContract.Args): Intent {
|
||||
return Intent(context, MediaSendV3Activity::class.java).putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
|
||||
return Intent(context, MediaSendV3Activity::class.java)
|
||||
.putExtra(MediaSendActivityContract.EXTRA_ARGS, args.copy(maxSelection = RemoteConfig.maxAttachmentCount))
|
||||
}
|
||||
|
||||
private fun RecipientId.toMediaRecipientId(): MediaRecipientId = MediaRecipientId(toLong())
|
||||
|
||||
@@ -6,7 +6,6 @@ sealed class HudCommand {
|
||||
object SaveMedia : HudCommand()
|
||||
|
||||
object GoToText : HudCommand()
|
||||
object GoToCapture : HudCommand()
|
||||
object GoToReview : HudCommand()
|
||||
|
||||
object ResumeEntryTransition : HudCommand()
|
||||
|
||||
+16
-33
@@ -26,7 +26,6 @@ import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.getWindowBreakpoint
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.Debouncer
|
||||
import org.signal.core.util.OVERRIDE_TRANSITION_CLOSE_COMPAT
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
@@ -46,11 +45,9 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -58,6 +55,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
class MediaSelectionActivity :
|
||||
PassphraseRequiredActivity(),
|
||||
MediaReviewFragment.Callback,
|
||||
TextStoryPostCreationFragment.Callback,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiEventListener,
|
||||
EmojiSearchFragment.Callback {
|
||||
@@ -70,17 +68,20 @@ class MediaSelectionActivity :
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val textViewModel: TextStoryPostCreationViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository())
|
||||
}
|
||||
)
|
||||
|
||||
private val addMessageCommandViewModel: EmojiKeyboardEventViewModel by viewModels()
|
||||
|
||||
private val destination: MediaSelectionDestination
|
||||
get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
|
||||
|
||||
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
|
||||
get() = (destination.getRecipientSearchKeyList() + destination.getRecipientSearchKey()).filterNotNull().toSet()
|
||||
|
||||
override val isAddToGroupStoryFlow: Boolean
|
||||
get() = intent.getBooleanExtra(IS_ADD_TO_GROUP_STORY_FLOW, false)
|
||||
|
||||
override val textStoryDraftText: CharSequence?
|
||||
get() = if (shareToTextStory) draftText else null
|
||||
|
||||
private val isStory: Boolean
|
||||
get() = intent.getBooleanExtra(IS_STORY, false)
|
||||
|
||||
@@ -136,7 +137,7 @@ class MediaSelectionActivity :
|
||||
selectedMedia = state?.selectedMedia ?: emptyList(),
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
MediaCaptureScreenEvent.ShowCamera -> debouncer.publish { viewModel.sendCommand(HudCommand.GoToCapture) }
|
||||
MediaCaptureScreenEvent.ShowCamera -> debouncer.publish { popTextStoryPostCreationFragment() }
|
||||
MediaCaptureScreenEvent.ShowTextStory -> viewModel.sendCommand(HudCommand.GoToText)
|
||||
MediaCaptureScreenEvent.NextClicked -> viewModel.sendCommand(HudCommand.GoToReview)
|
||||
is MediaCaptureScreenEvent.Camera -> Unit
|
||||
@@ -151,10 +152,6 @@ class MediaSelectionActivity :
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
if (shareToTextStory) {
|
||||
initializeTextStory()
|
||||
}
|
||||
|
||||
val navHostFragment = NavHostFragment.create(R.navigation.media)
|
||||
|
||||
supportFragmentManager
|
||||
@@ -165,7 +162,6 @@ class MediaSelectionActivity :
|
||||
navigateToStartDestination()
|
||||
} else {
|
||||
viewModel.onRestoreState(this, savedInstanceState)
|
||||
textViewModel.restoreFromInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
(supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment).navController.addOnDestinationChangedListener { _, d, _ ->
|
||||
@@ -239,22 +235,10 @@ class MediaSelectionActivity :
|
||||
viewModel.clearMediaErrors()
|
||||
}
|
||||
|
||||
private fun initializeTextStory() {
|
||||
val message = draftText?.toString() ?: return
|
||||
val firstLink = LinkPreviewUtil.findValidPreviewUrls(message).findFirst()
|
||||
val firstLinkUrl = firstLink.map { it.url }.orElse(null)
|
||||
|
||||
val iterator = BreakIteratorCompat.getInstance()
|
||||
iterator.setText(message)
|
||||
val trimmedMessage = iterator.take(700).toString()
|
||||
|
||||
if (firstLinkUrl == message) {
|
||||
textViewModel.setLinkPreview(firstLinkUrl)
|
||||
} else if (firstLinkUrl != null) {
|
||||
textViewModel.setLinkPreview(firstLinkUrl)
|
||||
textViewModel.setBody(trimmedMessage.replace(firstLinkUrl, "").trim())
|
||||
} else {
|
||||
textViewModel.setBody(trimmedMessage.trim())
|
||||
private fun popTextStoryPostCreationFragment() {
|
||||
val navController = findNavController(R.id.fragment_container)
|
||||
if (navController.currentDestination?.id == R.id.textStoryPostCreationFragment) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +259,6 @@ class MediaSelectionActivity :
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewModel.onSaveState(outState)
|
||||
textViewModel.saveToInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {
|
||||
|
||||
+69
-37
@@ -17,13 +17,15 @@ import androidx.core.view.postDelayed
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.camera.CameraDisplay
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
@@ -42,14 +44,14 @@ import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBin
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
|
||||
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Optional
|
||||
|
||||
@@ -58,20 +60,12 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
private var _binding: StoriesTextPostCreationFragmentBinding? = null
|
||||
private val binding: StoriesTextPostCreationFragmentBinding get() = _binding!!
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(
|
||||
ownerProducer = {
|
||||
requireActivity()
|
||||
}
|
||||
)
|
||||
private val callback: Callback
|
||||
get() = requireListener()
|
||||
|
||||
private val viewModel: TextStoryPostCreationViewModel by viewModels(
|
||||
ownerProducer = {
|
||||
requireActivity()
|
||||
},
|
||||
factoryProducer = {
|
||||
TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository())
|
||||
}
|
||||
)
|
||||
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
|
||||
TextStoryPostCreationViewModel.create(extras, callback.textStoryDraftText)
|
||||
}
|
||||
|
||||
private val linkPreviewViewModel: LinkPreviewViewModelV2 by activityViewModel { extras ->
|
||||
LinkPreviewViewModelV2(extras.createSavedStateHandle(), enablePlaceholder = true)
|
||||
@@ -95,11 +89,6 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
binding.storyTextPost.enableCreationMode()
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
lifecycleDisposable += sharedViewModel.hudCommands.subscribe {
|
||||
if (it == HudCommand.GoToCapture) {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.typeface.subscribeBy { typeface ->
|
||||
binding.storyTextPost.setTypeface(typeface)
|
||||
@@ -145,8 +134,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
if (it.isNotEmpty()) {
|
||||
performSend(it.toSet())
|
||||
} else {
|
||||
binding.send.isClickable = true
|
||||
binding.sendInProgressIndicator.visible = false
|
||||
onSendAborted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,9 +144,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
|
||||
binding.storyTextPost.disableCreationMode()
|
||||
|
||||
val contacts = (sharedViewModel.destination.getRecipientSearchKeyList() + sharedViewModel.destination.getRecipientSearchKey())
|
||||
.filterIsInstance(ContactSearchKey::class.java)
|
||||
.toSet()
|
||||
val contacts: Set<ContactSearchKey.RecipientSearchKey> = callback.textStoryDestinations
|
||||
|
||||
if (contacts.isEmpty()) {
|
||||
val bitmap = binding.storyTextPost.drawToBitmap()
|
||||
@@ -174,14 +160,8 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (sharedViewModel.isAddToGroupStoryFlow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(getString(R.string.MediaReviewFragment__add_to_the_group_story, sharedViewModel.state.value!!.recipient!!.getDisplayName(requireContext())))
|
||||
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend(contacts) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
binding.sendInProgressIndicator.visible = false
|
||||
}
|
||||
.show()
|
||||
} else if (callback.isAddToGroupStoryFlow) {
|
||||
confirmAddToGroupStory(contacts)
|
||||
} else {
|
||||
performSend(contacts)
|
||||
}
|
||||
@@ -284,6 +264,32 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmAddToGroupStory(contacts: Set<ContactSearchKey.RecipientSearchKey>) {
|
||||
val context = requireContext()
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val displayName = withContext(Dispatchers.Default) {
|
||||
Recipient.resolved(contacts.first().recipientId).getDisplayName(context)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setMessage(getString(R.string.MediaReviewFragment__add_to_the_group_story, displayName))
|
||||
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend(contacts) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> onSendAborted() }
|
||||
.setOnCancelListener { onSendAborted() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enables the screen after the user backs out of sending, since nothing else will restore it.
|
||||
*/
|
||||
private fun onSendAborted() {
|
||||
binding.send.isClickable = true
|
||||
binding.sendInProgressIndicator.visible = false
|
||||
binding.storyTextPost.enableCreationMode()
|
||||
}
|
||||
|
||||
private fun performSend(contacts: Set<ContactSearchKey>) {
|
||||
lifecycleDisposable += viewModel.send(
|
||||
contacts = contacts,
|
||||
@@ -292,7 +298,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
when (result) {
|
||||
TextStoryPostSendResult.Success -> {
|
||||
Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__sent_story, Toast.LENGTH_SHORT).show()
|
||||
requireActivity().finish()
|
||||
callback.onSentWithoutResult()
|
||||
}
|
||||
TextStoryPostSendResult.Failure -> {
|
||||
Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__failed_to_send_story, Toast.LENGTH_SHORT).show()
|
||||
@@ -331,4 +337,30 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
|
||||
}
|
||||
|
||||
override fun onCanceled() = Unit
|
||||
|
||||
/**
|
||||
* Flow-level information the host is responsible for, so that this fragment can be dropped into
|
||||
* any media send flow without knowing which one it is running in.
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
* The recipients to send to, or empty if the user still needs to select them.
|
||||
*/
|
||||
val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
|
||||
|
||||
val isAddToGroupStoryFlow: Boolean
|
||||
|
||||
/**
|
||||
* Text the flow was launched with, used to seed the post body and link preview.
|
||||
*/
|
||||
val textStoryDraftText: CharSequence?
|
||||
|
||||
/**
|
||||
* The story was sent by this flow, so there is no payload to hand back to whoever launched it.
|
||||
*
|
||||
* Shares the name and semantics of [MediaReviewFragment.Callback.onSentWithoutResult] so a host
|
||||
* supporting both flows can satisfy them with one implementation.
|
||||
*/
|
||||
fun onSentWithoutResult()
|
||||
}
|
||||
}
|
||||
|
||||
+55
-21
@@ -5,8 +5,11 @@ import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
@@ -17,6 +20,7 @@ import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
@@ -25,13 +29,19 @@ import org.thoughtcrime.securesms.fonts.TextFont
|
||||
import org.thoughtcrime.securesms.fonts.TextToScript
|
||||
import org.thoughtcrime.securesms.fonts.TypefaceCache
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRepository, private val identityChangesSince: Long = System.currentTimeMillis()) : ViewModel() {
|
||||
class TextStoryPostCreationViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: TextStoryPostSendRepository,
|
||||
draftText: CharSequence? = null,
|
||||
private val identityChangesSince: Long = System.currentTimeMillis()
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = RxStore(TextStoryPostCreationState())
|
||||
private val store = RxStore(restoreState(savedStateHandle) ?: buildInitialState(draftText))
|
||||
private val textFontSubject: Subject<TextFont> = BehaviorSubject.create()
|
||||
private val temporaryBodySubject: Subject<String> = BehaviorSubject.createDefault("")
|
||||
private val disposables = CompositeDisposable()
|
||||
@@ -42,6 +52,10 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
|
||||
val typeface: Flowable<Typeface> = internalTypeface.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
init {
|
||||
savedStateHandle.setSavedStateProvider(TEXT_STORY_INSTANCE_STATE) {
|
||||
bundleOf(TEXT_STORY_INSTANCE_STATE to store.state)
|
||||
}
|
||||
|
||||
textFontSubject.onNext(store.state.textFont)
|
||||
|
||||
val scriptGuess = temporaryBodySubject.observeOn(Schedulers.io()).map { TextToScript.guessScript(it) }
|
||||
@@ -64,18 +78,6 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun saveToInstanceState(outState: Bundle) {
|
||||
outState.putParcelable(TEXT_STORY_INSTANCE_STATE, store.state)
|
||||
}
|
||||
|
||||
fun restoreFromInstanceState(inState: Bundle) {
|
||||
if (inState.containsKey(TEXT_STORY_INSTANCE_STATE)) {
|
||||
val state: TextStoryPostCreationState = inState.getParcelableCompat(TEXT_STORY_INSTANCE_STATE, TextStoryPostCreationState::class.java)!!
|
||||
textFontSubject.onNext(store.state.textFont)
|
||||
store.update { state }
|
||||
}
|
||||
}
|
||||
|
||||
fun getBody(): CharSequence {
|
||||
return store.state.body
|
||||
}
|
||||
@@ -135,14 +137,46 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
|
||||
return store.state.linkPreviewUri
|
||||
}
|
||||
|
||||
class Factory(private val repository: TextStoryPostSendRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(TextStoryPostCreationViewModel(repository)) as T
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java)
|
||||
private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state"
|
||||
private const val MAX_DRAFT_LENGTH = 700
|
||||
|
||||
fun create(extras: CreationExtras, draftText: CharSequence? = null): TextStoryPostCreationViewModel {
|
||||
return TextStoryPostCreationViewModel(
|
||||
savedStateHandle = extras.createSavedStateHandle(),
|
||||
repository = TextStoryPostSendRepository(),
|
||||
draftText = draftText
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreState(savedStateHandle: SavedStateHandle): TextStoryPostCreationState? {
|
||||
return savedStateHandle
|
||||
.get<Bundle>(TEXT_STORY_INSTANCE_STATE)
|
||||
?.getParcelableCompat(TEXT_STORY_INSTANCE_STATE, TextStoryPostCreationState::class.java)
|
||||
}
|
||||
|
||||
private fun buildInitialState(draftText: CharSequence?): TextStoryPostCreationState {
|
||||
val message: String = draftText?.toString()?.takeIf { it.isNotBlank() } ?: return TextStoryPostCreationState()
|
||||
|
||||
val firstLinkUrl: String? = LinkPreviewUtil
|
||||
.findValidPreviewUrls(message)
|
||||
.findFirst()
|
||||
.map { it.url }
|
||||
.orElse(null)
|
||||
|
||||
val iterator = BreakIteratorCompat.getInstance()
|
||||
iterator.setText(message)
|
||||
val trimmedMessage = iterator.take(MAX_DRAFT_LENGTH).toString()
|
||||
|
||||
return when {
|
||||
firstLinkUrl == null -> TextStoryPostCreationState(body = trimmedMessage.trim())
|
||||
firstLinkUrl == message -> TextStoryPostCreationState(linkPreviewUri = firstLinkUrl)
|
||||
else -> TextStoryPostCreationState(
|
||||
body = trimmedMessage.replace(firstLinkUrl, "").trim(),
|
||||
linkPreviewUri = firstLinkUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -6,7 +6,6 @@ import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -17,6 +16,7 @@ import org.thoughtcrime.securesms.stories.StoryLinkPreviewView
|
||||
import org.thoughtcrime.securesms.util.LinkUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
@@ -30,7 +30,9 @@ class TextStoryPostLinkEntryFragment(private val shouldPreset: Boolean = false)
|
||||
factoryProducer = { LinkPreviewViewModel.Factory(LinkPreviewRepository(), true) }
|
||||
)
|
||||
|
||||
private val viewModel: TextStoryPostCreationViewModel by activityViewModels()
|
||||
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
|
||||
TextStoryPostCreationViewModel.create(extras)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
input = view.findViewById(R.id.input)
|
||||
|
||||
+4
-6
@@ -22,7 +22,6 @@ import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.transition.TransitionManager
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
@@ -37,6 +36,7 @@ import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
|
||||
import java.util.Locale
|
||||
@@ -48,11 +48,9 @@ class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment(
|
||||
contentLayoutId = R.layout.stories_text_post_text_entry_fragment
|
||||
) {
|
||||
|
||||
private val viewModel: TextStoryPostCreationViewModel by viewModels(
|
||||
ownerProducer = {
|
||||
requireActivity()
|
||||
}
|
||||
)
|
||||
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
|
||||
TextStoryPostCreationViewModel.create(extras)
|
||||
}
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
package org.thoughtcrime.securesms.mediasend.v3
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -21,6 +23,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.mediasend.HudCommand
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.signal.mediasend.MediaSendRecipient
|
||||
import org.signal.mediasend.MediaSendScreen
|
||||
import org.signal.mediasend.MediaSendViewModel
|
||||
import org.signal.mediasend.edit.LocalAddAMessageRowTextField
|
||||
@@ -29,7 +32,9 @@ import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.v2.QuickRestoreInfoDialog
|
||||
import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
|
||||
@@ -39,13 +44,31 @@ import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
/**
|
||||
* Encapsulates the media send flow for v3.
|
||||
*/
|
||||
class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomSheet.Callbacks {
|
||||
class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomSheet.Callbacks, TextStoryPostCreationFragment.Callback {
|
||||
|
||||
private val contractArgs: MediaSendActivityContract.Args by lazy { MediaSendActivityContract.Args.fromIntent(intent) }
|
||||
|
||||
private val viewModel: MediaSendViewModel by viewModels { MediaSendViewModel.Factory(args = contractArgs) }
|
||||
|
||||
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
|
||||
get() = destinations().toSet()
|
||||
|
||||
override val isAddToGroupStoryFlow: Boolean
|
||||
get() = contractArgs.isAddToGroupStoryFlow
|
||||
|
||||
override val textStoryDraftText: CharSequence?
|
||||
get() = if (contractArgs.asTextStory) contractArgs.initialMessage else null
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
super.attachBaseContext(newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
if (savedInstanceState == null && contractArgs.isForQuickRestore) {
|
||||
QuickRestoreInfoDialog.show(supportFragmentManager)
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(AddMessageDialogFragment.REQUEST_KEY, this) { _, bundle ->
|
||||
if (bundle.getBoolean(AddMessageDialogFragment.RESULT_INCREMENT_VIEW_ONCE_STATE)) {
|
||||
viewModel.setMessage(null)
|
||||
@@ -69,6 +92,12 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
|
||||
) {
|
||||
MediaSendScreen(
|
||||
contractArgs = contractArgs,
|
||||
textStoryEditorSlot = {
|
||||
AndroidFragment(
|
||||
clazz = TextStoryPostCreationFragment::class.java,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
},
|
||||
sendSlot = {
|
||||
AndroidFragment(
|
||||
clazz = MediaSendV3ForwardFragment::class.java,
|
||||
@@ -112,6 +141,8 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
|
||||
|
||||
is HudCommand.FinishWithResult -> finishWithResult(it.payload)
|
||||
|
||||
is HudCommand.FinishWithoutResult -> onSentWithoutResult()
|
||||
|
||||
is HudCommand.ResolveUntrustedIdentities -> {
|
||||
SafetyNumberBottomSheet
|
||||
.forRecipientIdsAndDestinations(it.untrustedRecipientIds.map(RecipientId::from), destinations())
|
||||
@@ -129,6 +160,11 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSentWithoutResult() {
|
||||
setResult(RESULT_OK, Intent())
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
|
||||
viewModel.performSend()
|
||||
}
|
||||
@@ -142,9 +178,15 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
|
||||
finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads from state rather than the launch args so that recipients chosen inside the flow are included.
|
||||
*/
|
||||
private fun destinations(): List<ContactSearchKey.RecipientSearchKey> {
|
||||
return contractArgs.recipientId
|
||||
?.let { listOf(ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), contractArgs.isStory)) }
|
||||
?: emptyList()
|
||||
val state = viewModel.state.value
|
||||
val single = state.recipientId?.let { MediaSendRecipient(it, state.isStory) }
|
||||
|
||||
return (listOfNotNull(single) + state.additionalRecipients)
|
||||
.distinct()
|
||||
.map { ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id.id), it.isStory) }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-35
@@ -11,21 +11,16 @@ import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.getParcelableArrayListCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
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.signal.mediasend.SendResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -38,10 +33,6 @@ import org.signal.core.ui.R as CoreUiR
|
||||
*/
|
||||
class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaSendV3ForwardFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: MediaSendViewModel by activityViewModels {
|
||||
MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent))
|
||||
}
|
||||
@@ -78,31 +69,9 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit
|
||||
val selectedRecipients: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)
|
||||
?: emptyList()
|
||||
|
||||
val recipientIds = selectedRecipients.map { MediaRecipientId(it.recipientId.toLong()) }
|
||||
viewModel.setAdditionalRecipients(recipientIds)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
when (val result = viewModel.send()) {
|
||||
is SendResult.Success -> {
|
||||
Log.d(TAG, "Send completed successfully.")
|
||||
requireActivity().finish()
|
||||
}
|
||||
is SendResult.Error -> {
|
||||
Log.w(TAG, "Send failed: ${result.message}")
|
||||
requireActivity().finish()
|
||||
}
|
||||
is SendResult.ReadyToSend -> {
|
||||
Log.w(TAG, "Unexpected hand-off to the caller from contact selection.")
|
||||
requireActivity().finish()
|
||||
}
|
||||
is SendResult.UntrustedIdentity -> {
|
||||
Log.w(TAG, "Send failed due to untrusted identities.")
|
||||
SafetyNumberBottomSheet
|
||||
.forRecipientIdsAndDestinations(result.recipientIds.map { RecipientId.from(it) }, selectedRecipients)
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
val recipients = selectedRecipients.map { MediaSendRecipient(MediaRecipientId(it.recipientId.toLong()), it.isStory) }
|
||||
viewModel.setAdditionalRecipients(recipients)
|
||||
viewModel.performSend()
|
||||
}
|
||||
|
||||
override fun getContainer(): ViewGroup {
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.signal.mediasend.MediaConstraints
|
||||
import org.signal.mediasend.MediaFilterError
|
||||
import org.signal.mediasend.MediaFilterResult
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendRecipient
|
||||
import org.signal.mediasend.MediaSendRepository
|
||||
import org.signal.mediasend.SendRequest
|
||||
import org.signal.mediasend.SendResult
|
||||
@@ -225,16 +226,16 @@ object MediaSendV3Repository : MediaSendRepository {
|
||||
*/
|
||||
private fun buildSingleContact(request: SendRequest): ContactSearchKey.RecipientSearchKey? {
|
||||
return request.singleRecipientId
|
||||
?.takeIf { request.recipientIds.isEmpty() }
|
||||
?.takeIf { request.recipients.isEmpty() }
|
||||
?.let { ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory) }
|
||||
}
|
||||
|
||||
private fun buildRecipients(request: SendRequest): List<ContactSearchKey.RecipientSearchKey> {
|
||||
return buildList {
|
||||
request.singleRecipientId?.let { add(it) }
|
||||
addAll(request.recipientIds)
|
||||
}.distinctBy(MediaRecipientId::id).map {
|
||||
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory)
|
||||
request.singleRecipientId?.let { add(MediaSendRecipient(it, request.isStory)) }
|
||||
addAll(request.recipients)
|
||||
}.distinct().map {
|
||||
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id.id), it.isStory)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ class StoryLinkPreviewView @JvmOverloads constructor(
|
||||
private val binding = StoriesTextPostLinkPreviewBinding.bind(this)
|
||||
private val spinnerStub = Stub<View>(binding.loadingSpinner)
|
||||
|
||||
private var canClose: Boolean = false
|
||||
private var hasContent: Boolean = false
|
||||
|
||||
init {
|
||||
binding.linkPreviewImage.isClickable = false
|
||||
binding.linkPreviewLarge.isClickable = false
|
||||
@@ -70,6 +73,9 @@ class StoryLinkPreviewView @JvmOverloads constructor(
|
||||
visibility = hiddenVisibility
|
||||
}
|
||||
|
||||
hasContent = linkPreview != null
|
||||
updateCloseVisibility()
|
||||
|
||||
return future ?: SettableFuture(false)
|
||||
}
|
||||
|
||||
@@ -85,6 +91,8 @@ class StoryLinkPreviewView @JvmOverloads constructor(
|
||||
spinnerStub.get().visible = linkPreviewState.isLoading
|
||||
if (linkPreviewState.isLoading) {
|
||||
visible = true
|
||||
hasContent = true
|
||||
updateCloseVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +183,12 @@ class StoryLinkPreviewView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun setCanClose(canClose: Boolean) {
|
||||
binding.linkPreviewClose.visible = canClose
|
||||
this.canClose = canClose
|
||||
updateCloseVisibility()
|
||||
}
|
||||
|
||||
private fun updateCloseVisibility() {
|
||||
binding.linkPreviewClose.visible = canClose && hasContent
|
||||
}
|
||||
|
||||
private fun formatDate(date: Long): String? {
|
||||
|
||||
@@ -57,6 +57,10 @@ class MediaSendActivityContract(private val clazz: Class<out Activity>) : Activi
|
||||
* Optional recipient identifier for single-recipient flows.
|
||||
*/
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
/**
|
||||
* Recipients known up front for multi-recipient flows, in addition to [recipientId].
|
||||
*/
|
||||
val additionalRecipients: List<MediaSendRecipient> = emptyList(),
|
||||
val mode: Mode = Mode.SingleRecipient,
|
||||
/**
|
||||
* Initial media to populate the selection.
|
||||
@@ -80,6 +84,14 @@ class MediaSendActivityContract(private val clazz: Class<out Activity>) : Activi
|
||||
* Whether this is specifically the "add to group story" flow.
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Whether the flow should open directly into the text story editor, seeded with [initialMessage].
|
||||
*/
|
||||
val asTextStory: Boolean = false,
|
||||
/**
|
||||
* Whether the flow was entered to scan a quick restore QR code, which shows an explanatory sheet.
|
||||
*/
|
||||
val isForQuickRestore: Boolean = false,
|
||||
/**
|
||||
* Maximum number of media items that can be selected.
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,12 @@ sealed interface HudCommand {
|
||||
*/
|
||||
data class FinishWithResult(val payload: Parcelable) : HudCommand
|
||||
|
||||
/**
|
||||
* The flow performed the send itself, so there is no payload. Callers still need to be told the send
|
||||
* succeeded, as opposed to [CloseScreen] which is a cancellation.
|
||||
*/
|
||||
data object FinishWithoutResult : HudCommand
|
||||
|
||||
/** The send was blocked by safety number changes for [untrustedRecipientIds]. */
|
||||
data class ResolveUntrustedIdentities(val untrustedRecipientIds: List<Long>) : HudCommand
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* A recipient the flow will send to, along with whether the send targets that recipient's story.
|
||||
*
|
||||
* Story-ness is per-recipient rather than per-flow because a single send can mix targets, e.g. a group
|
||||
* chat alongside that same group's story.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MediaSendRecipient(
|
||||
val id: MediaRecipientId,
|
||||
val isStory: Boolean = false
|
||||
) : Parcelable
|
||||
@@ -140,7 +140,7 @@ data class SendRequest(
|
||||
val message: CharSequence?,
|
||||
val isViewOnce: Boolean,
|
||||
val singleRecipientId: MediaRecipientId?,
|
||||
val recipientIds: List<MediaRecipientId>,
|
||||
val recipients: List<MediaSendRecipient>,
|
||||
val scheduledTime: Long,
|
||||
val sendType: Int,
|
||||
val isStory: Boolean,
|
||||
|
||||
@@ -105,9 +105,9 @@ data class MediaSendState(
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Additional recipient IDs for multi-recipient sends.
|
||||
* Additional recipients for multi-recipient sends.
|
||||
*/
|
||||
val additionalRecipientIds: List<MediaRecipientId> = emptyList(),
|
||||
val additionalRecipients: List<MediaSendRecipient> = emptyList(),
|
||||
/**
|
||||
* Scheduled send time (-1 for immediate).
|
||||
*/
|
||||
|
||||
@@ -89,12 +89,13 @@ class MediaSendViewModel(
|
||||
private val defaultState = MediaSendState(
|
||||
isCameraFirst = args.isCameraFirst,
|
||||
recipientId = args.recipientId,
|
||||
additionalRecipients = args.additionalRecipients,
|
||||
mode = args.mode,
|
||||
isStory = args.isStory,
|
||||
isReply = args.isReply,
|
||||
isAddToGroupStoryFlow = args.isAddToGroupStoryFlow,
|
||||
maxSelection = args.maxSelection,
|
||||
message = args.initialMessage,
|
||||
message = if (args.asTextStory) null else args.initialMessage,
|
||||
isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
|
||||
sendType = args.sendType
|
||||
)
|
||||
@@ -104,6 +105,7 @@ class MediaSendViewModel(
|
||||
key = KEY_BACK_STACK
|
||||
) {
|
||||
val startKey = when {
|
||||
args.asTextStory -> MediaSendNavKey.Capture.TextStory
|
||||
args.isCameraFirst -> MediaSendNavKey.Capture.Camera
|
||||
args.initialMedia.isNotEmpty() -> MediaSendNavKey.Edit
|
||||
else -> MediaSendNavKey.Select.Folders
|
||||
@@ -861,8 +863,11 @@ class MediaSendViewModel(
|
||||
|
||||
fun getStorySendRequirements(): StorySendRequirements = state.value.storySendRequirements
|
||||
|
||||
/**
|
||||
* Computed for every flow, not just story flows: the contact picker consults this before allowing a story
|
||||
* to be selected, so leaving it at its default would strip story selections made mid-flow.
|
||||
*/
|
||||
private suspend fun updateStorySendRequirements(media: List<Media>) {
|
||||
if (!state.value.isStory) return
|
||||
val requirements = repository.getStorySendRequirements(media)
|
||||
updateState { copy(storySendRequirements = requirements) }
|
||||
}
|
||||
@@ -871,8 +876,8 @@ class MediaSendViewModel(
|
||||
|
||||
//region Recipients
|
||||
|
||||
fun setAdditionalRecipients(recipientIds: List<MediaRecipientId>) {
|
||||
updateState { copy(additionalRecipientIds = recipientIds) }
|
||||
fun setAdditionalRecipients(recipients: List<MediaSendRecipient>) {
|
||||
updateState { copy(additionalRecipients = recipients) }
|
||||
}
|
||||
|
||||
fun setScheduledTime(time: Long) {
|
||||
@@ -939,7 +944,7 @@ class MediaSendViewModel(
|
||||
viewModelScope.launch {
|
||||
when (val result = send()) {
|
||||
is SendResult.ReadyToSend -> sendHudCommand(HudCommand.FinishWithResult(result.payload))
|
||||
is SendResult.Success -> sendHudCommand(HudCommand.CloseScreen)
|
||||
is SendResult.Success -> sendHudCommand(HudCommand.FinishWithoutResult)
|
||||
|
||||
is SendResult.UntrustedIdentity -> {
|
||||
updateState { copy(isSending = false) }
|
||||
@@ -966,7 +971,7 @@ class MediaSendViewModel(
|
||||
// Check for untrusted identities
|
||||
val allRecipientIds = buildSet {
|
||||
snapshot.recipientId?.let { add(it.id) }
|
||||
addAll(snapshot.additionalRecipientIds.map { it.id })
|
||||
addAll(snapshot.additionalRecipients.map { it.id.id })
|
||||
}
|
||||
|
||||
if (allRecipientIds.isNotEmpty()) {
|
||||
@@ -983,7 +988,7 @@ class MediaSendViewModel(
|
||||
message = snapshot.message,
|
||||
isViewOnce = isViewOnceEnabled(),
|
||||
singleRecipientId = snapshot.recipientId,
|
||||
recipientIds = snapshot.additionalRecipientIds,
|
||||
recipients = snapshot.additionalRecipients,
|
||||
scheduledTime = snapshot.scheduledTime,
|
||||
sendType = snapshot.sendType,
|
||||
isStory = snapshot.isStory,
|
||||
|
||||
+38
-7
@@ -5,6 +5,12 @@
|
||||
|
||||
package org.signal.mediasend.capture
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
@@ -55,6 +61,12 @@ import org.signal.mediasend.R
|
||||
import org.signal.mediasend.edit.rememberPreviewMedia
|
||||
import org.signal.mediasend.rememberPreviewState
|
||||
|
||||
/**
|
||||
* The text story editor slides in over a stationary camera, so it always sits on top.
|
||||
*/
|
||||
private const val CAMERA_Z_INDEX = 0f
|
||||
private const val TEXT_STORY_Z_INDEX = 1f
|
||||
|
||||
/**
|
||||
* Screen that allows user to capture the media they will send using a camera or text story
|
||||
*/
|
||||
@@ -70,13 +82,32 @@ fun MediaCaptureScreen(
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
) {
|
||||
when (selectedCaptureScreen) {
|
||||
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
|
||||
else -> {
|
||||
MediaCameraCaptureScreen(
|
||||
state = state,
|
||||
onEvent = onEvent
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = selectedCaptureScreen,
|
||||
transitionSpec = {
|
||||
if (targetState is MediaSendNavKey.Capture.TextStory) {
|
||||
ContentTransform(
|
||||
targetContentEnter = slideInHorizontally { width -> width },
|
||||
initialContentExit = ExitTransition.KeepUntilTransitionsFinished,
|
||||
targetContentZIndex = TEXT_STORY_Z_INDEX
|
||||
)
|
||||
} else {
|
||||
ContentTransform(
|
||||
targetContentEnter = EnterTransition.None,
|
||||
initialContentExit = slideOutHorizontally { width -> width },
|
||||
targetContentZIndex = CAMERA_Z_INDEX
|
||||
)
|
||||
}
|
||||
}
|
||||
) { captureScreen ->
|
||||
when (captureScreen) {
|
||||
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
|
||||
else -> {
|
||||
MediaCameraCaptureScreen(
|
||||
state = state,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user