Add entry points for adding to a group story.

This commit is contained in:
Alex Hart
2022-11-29 13:12:56 -04:00
committed by Cody Henthorne
parent 7949996c5c
commit 7b13550086
19 changed files with 372 additions and 17 deletions

View File

@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
@@ -137,6 +138,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private lateinit var toolbarBadge: BadgeImageView
private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View
private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate
private val navController get() = Navigation.findNavController(requireView())
@@ -221,6 +223,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
}
addToGroupStoryDelegate = AddToGroupStoryDelegate(this)
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.recipient != Recipient.UNKNOWN) {
@@ -368,6 +371,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
ButtonStripPreference.Model(
state = state.buttonStripState,
onAddToStoryClick = {
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationSettingsFragment__cant_add_to_group_story)
.setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else {
addToGroupStoryDelegate.addToStory(state.recipient.id)
}
},
onVideoClick = {
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
MaterialAlertDialogBuilder(requireContext())

View File

@@ -272,7 +272,8 @@ sealed class ConversationSettingsViewModel(
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = true
isSearchAvailable = true,
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
specificSettingsState = state.requireGroupSettingsState().copy(

View File

@@ -24,6 +24,7 @@ object ButtonStripPreference {
class Model(
val state: State,
val background: DSLSettingsIcon? = null,
val onAddToStoryClick: () -> Unit = {},
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
val onAudioClick: () -> Unit = {},
@@ -41,6 +42,8 @@ object ButtonStripPreference {
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val addToStory: View = itemView.findViewById(R.id.add_to_story)
private val addToStoryContainer: View = itemView.findViewById(R.id.button_strip_add_to_story_container)
private val message: View = itemView.findViewById(R.id.message)
private val messageContainer: View = itemView.findViewById(R.id.button_strip_message_container)
private val videoCall: View = itemView.findViewById(R.id.start_video)
@@ -60,6 +63,7 @@ object ButtonStripPreference {
audioContainer.visible = model.state.isAudioAvailable
muteContainer.visible = model.state.isMuteAvailable
searchContainer.visible = model.state.isSearchAvailable
addToStoryContainer.visible = model.state.isAddToStoryAvailable
if (model.state.isAudioSecure) {
audioLabel.setText(R.string.ConversationSettingsFragment__audio)
@@ -88,6 +92,7 @@ object ButtonStripPreference {
audioCall.setOnClickListener { model.onAudioClick() }
mute.setOnClickListener { model.onMuteClick() }
search.setOnClickListener { model.onSearchClick() }
addToStory.setOnClickListener { model.onAddToStoryClick() }
}
}
@@ -99,5 +104,6 @@ object ButtonStripPreference {
val isSearchAvailable: Boolean = false,
val isAudioSecure: Boolean = false,
val isMuted: Boolean = false,
val isAddToStoryAvailable: Boolean = false
)
}

View File

@@ -93,8 +93,9 @@ class MediaSelectionActivity :
val initialMedia: List<Media> = intent.getParcelableArrayListExtra(MEDIA) ?: listOf()
val message: CharSequence? = if (shareToTextStory) null else draftText
val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false)
val isAddToGroupStoryFlow: Boolean = intent.getBooleanExtra(IS_ADD_TO_GROUP_STORY_FLOW, false)
val factory = MediaSelectionViewModel.Factory(destination, sendType, initialMedia, message, isReply, isStory, MediaSelectionRepository(this))
val factory = MediaSelectionViewModel.Factory(destination, sendType, initialMedia, message, isReply, isStory, isAddToGroupStoryFlow, MediaSelectionRepository(this))
viewModel = ViewModelProvider(this, factory)[MediaSelectionViewModel::class.java]
val textStoryToggle: ConstraintLayout = findViewById(R.id.switch_widget)
@@ -221,7 +222,7 @@ class MediaSelectionActivity :
return Stories.isFeatureEnabled() &&
isCameraFirst() &&
!viewModel.hasSelectedMedia() &&
destination == MediaSelectionDestination.ChooseAfterMediaSelection
(destination == MediaSelectionDestination.ChooseAfterMediaSelection || destination is MediaSelectionDestination.SingleStory)
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -348,6 +349,7 @@ class MediaSelectionActivity :
private const val IS_REPLY = "is_reply"
private const val IS_STORY = "is_story"
private const val AS_TEXT_STORY = "as_text_story"
private const val IS_ADD_TO_GROUP_STORY_FLOW = "is_add_to_group_story_flow"
@JvmStatic
fun camera(context: Context): Intent {
@@ -363,6 +365,19 @@ class MediaSelectionActivity :
)
}
fun addToGroupStory(
context: Context,
recipientId: RecipientId
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
isStory = true,
isAddToGroupStoryFlow = true,
destination = MediaSelectionDestination.SingleStory(recipientId)
)
}
@JvmStatic
fun camera(
context: Context,
@@ -457,7 +472,8 @@ class MediaSelectionActivity :
message: CharSequence? = null,
isReply: Boolean = false,
isStory: Boolean = false,
asTextStory: Boolean = false
asTextStory: Boolean = false,
isAddToGroupStoryFlow: Boolean = false
): Intent {
return Intent(context, MediaSelectionActivity::class.java).apply {
putExtra(START_ACTION, startAction)
@@ -468,6 +484,7 @@ class MediaSelectionActivity :
putExtra(IS_REPLY, isReply)
putExtra(IS_STORY, isStory)
putExtra(AS_TEXT_STORY, asTextStory)
putExtra(IS_ADD_TO_GROUP_STORY_FLOW, isAddToGroupStoryFlow)
}
}
}

View File

@@ -38,6 +38,16 @@ sealed class MediaSelectionDestination {
}
}
class SingleStory(private val id: RecipientId) : MediaSelectionDestination() {
override fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey = ContactSearchKey.RecipientSearchKey.Story(id)
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelable(STORY, id)
}
}
}
class MultipleRecipients(val recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>) : MediaSelectionDestination() {
companion object {
@@ -72,6 +82,7 @@ sealed class MediaSelectionDestination {
private const val WALLPAPER = "wallpaper"
private const val AVATAR = "avatar"
private const val RECIPIENT = "recipient"
private const val STORY = "story"
private const val RECIPIENT_LIST = "recipient_list"
fun fromBundle(bundle: Bundle): MediaSelectionDestination {
@@ -79,6 +90,7 @@ sealed class MediaSelectionDestination {
bundle.containsKey(WALLPAPER) -> Wallpaper
bundle.containsKey(AVATAR) -> Avatar
bundle.containsKey(RECIPIENT) -> SingleRecipient(requireNotNull(bundle.getParcelable(RECIPIENT)))
bundle.containsKey(STORY) -> SingleStory(requireNotNull(bundle.getParcelable(STORY)))
bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients.fromParcel(requireNotNull(bundle.getParcelableArrayList(RECIPIENT_LIST)))
else -> ChooseAfterMediaSelection
}

View File

@@ -105,6 +105,8 @@ class MediaSelectionRepository(context: Context) {
val singleRecipient: Recipient? = singleContact?.let { Recipient.resolved(it.recipientId) }
val storyType: StoryType = if (singleRecipient?.isDistributionList == true) {
SignalDatabase.distributionLists.getStoryType(singleRecipient.requireDistributionListId())
} else if (singleRecipient?.isGroup == true && singleContact.isStory) {
StoryType.STORY_WITH_REPLIES
} else {
StoryType.NONE
}

View File

@@ -45,6 +45,7 @@ class MediaSelectionViewModel(
initialMessage: CharSequence?,
val isReply: Boolean,
isStory: Boolean,
val isAddToGroupStoryFlow: Boolean,
private val repository: MediaSelectionRepository,
private val identityChangesSince: Long = System.currentTimeMillis()
) : ViewModel() {
@@ -360,10 +361,10 @@ class MediaSelectionViewModel(
return
}
val filteredPreUploadMedia = if (Stories.isFeatureEnabled()) {
media.filter { Stories.MediaTransform.canPreUploadMedia(it) }
} else {
val filteredPreUploadMedia = if (destination is MediaSelectionDestination.SingleRecipient || !Stories.isFeatureEnabled()) {
media
} else {
media.filter { Stories.MediaTransform.canPreUploadMedia(it) }
}
repository.uploadRepository.startUpload(filteredPreUploadMedia, store.state.recipient)
@@ -482,10 +483,11 @@ class MediaSelectionViewModel(
private val initialMessage: CharSequence?,
private val isReply: Boolean,
private val isStory: Boolean,
private val isAddToGroupStoryFlow: Boolean,
private val repository: MediaSelectionRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaSelectionViewModel(destination, sendType, initialMedia, initialMessage, isReply, isStory, repository)))
return requireNotNull(modelClass.cast(MediaSelectionViewModel(destination, sendType, initialMedia, initialMessage, isReply, isStory, isAddToGroupStoryFlow, repository)))
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import app.cash.exhaustive.Exhaustive
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.concurrent.SimpleTask
import org.thoughtcrime.securesms.R
@@ -200,6 +201,12 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
} else {
multiselectLauncher.launch(args)
}
} 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() }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
} else {
performSend()
}
@@ -317,7 +324,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
.setInterpolator(MediaAnimations.interpolator)
.alpha(1f)
sharedViewModel
disposables += sharedViewModel
.send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
.subscribe(
{ result -> callback.onSentWithResult(result) },

View File

@@ -12,6 +12,7 @@ import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -157,6 +158,12 @@ 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) { _, _ -> }
.show()
} else {
performSend(contacts)
}

View File

@@ -231,18 +231,20 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
!recipient.isReleaseNotes();
ButtonStripPreference.State buttonStripState = new ButtonStripPreference.State(
/* isMessageAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && !recipient.isReleaseNotes(),
/* isVideoAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && recipient.isRegistered(),
/* isAudioAvailable = */ isAudioAvailable,
/* isMuteAvailable = */ false,
/* isSearchAvailable = */ false,
/* isAudioSecure = */ recipient.isRegistered(),
/* isMuted = */ false
/* isMessageAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && !recipient.isReleaseNotes(),
/* isVideoAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && recipient.isRegistered(),
/* isAudioAvailable = */ isAudioAvailable,
/* isMuteAvailable = */ false,
/* isSearchAvailable = */ false,
/* isAudioSecure = */ recipient.isRegistered(),
/* isMuted = */ false,
/* isAddToStoryAvailable = */ false
);
ButtonStripPreference.Model buttonStripModel = new ButtonStripPreference.Model(
buttonStripState,
DSLSettingsIcon.from(ContextUtil.requireDrawable(requireContext(), R.drawable.selectable_recipient_bottom_sheet_icon_button)),
() -> Unit.INSTANCE,
() -> {
dismiss();
viewModel.onMessageClicked(requireActivity());

View File

@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.stories.viewer
import android.content.Intent
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.CompletableSubject
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Delegate for dealing with sending stories directly to a group.
*/
class AddToGroupStoryDelegate(
private val fragment: Fragment
) {
companion object {
private val TAG = Log.tag(AddToGroupStoryDelegate::class.java)
}
private val lifecycleDisposable = LifecycleDisposable().apply {
bindTo(fragment.viewLifecycleOwner)
}
private val addToStoryLauncher: ActivityResultLauncher<Intent> = fragment.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data
if (data == null) {
Log.d(TAG, "No result data.")
} else {
Log.d(TAG, "Processing result...")
val mediaSelectionResult: MediaSendActivityResult = MediaSendActivityResult.fromData(data)
handleResult(mediaSelectionResult)
}
}
fun addToStory(recipientId: RecipientId) {
val addToStoryIntent = MediaSelectionActivity.addToGroupStory(
fragment.requireContext(),
recipientId
)
addToStoryLauncher.launch(addToStoryIntent)
}
private fun handleResult(result: MediaSendActivityResult) {
lifecycleDisposable += ResultHandler.handleResult(result)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
Toast.makeText(fragment.requireContext(), R.string.TextStoryPostCreationFragment__sent_story, Toast.LENGTH_SHORT).show()
}
}
/**
* Dispatches the send result on a background thread, isolated from the fragment.
*/
private object ResultHandler {
/**
* Handles the result, completing after sending the message.
*/
@CheckResult
fun handleResult(result: MediaSendActivityResult): Completable {
Log.d(TAG, "Dispatching result handler.")
val subject = CompletableSubject.create()
SignalExecutors.BOUNDED_IO.execute {
if (result.isPushPreUpload) {
sendPreUploadedMedia(result)
} else {
sendNonPreUploadedMedia(result)
}
subject.onComplete()
}
return subject
}
@WorkerThread
private fun sendPreUploadedMedia(result: MediaSendActivityResult) {
Log.d(TAG, "Sending preupload media.")
val recipient = Recipient.resolved(result.recipientId)
val secureMessage = OutgoingSecureMediaMessage(
OutgoingMediaMessage(
Recipient.resolved(result.recipientId),
SlideDeck(),
"",
System.currentTimeMillis(),
-1,
0,
false,
ThreadTable.DistributionTypes.DEFAULT,
result.storyType,
null,
false,
null,
emptyList(),
emptyList(),
result.mentions.toList(),
null
)
)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
if (result.body.isNotEmpty()) {
result.preUploadResults.forEach {
SignalDatabase.attachments.updateAttachmentCaption(it.attachmentId, result.body)
}
}
MessageSender.sendPushWithPreUploadedMedia(
ApplicationDependencies.getApplication(),
secureMessage,
result.preUploadResults,
threadId
) {
Log.d(TAG, "Sent.")
}
}
@WorkerThread
private fun sendNonPreUploadedMedia(result: MediaSendActivityResult) {
Log.d(TAG, "Sending non-preupload media.")
val multiShareArgs = MultiShareArgs.Builder(setOf(ContactSearchKey.RecipientSearchKey.Story(result.recipientId)))
.withMedia(result.nonUploadedMedia.toList())
.withDraftText(result.body)
.withMentions(result.mentions.toList())
.build()
val results = MultiShareSender.sendSync(multiShareArgs)
Log.d(TAG, "Sent. Failures? ${results.containsFailures()}")
}
}
}

View File

@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.stories.StorySlateView
import org.thoughtcrime.securesms.stories.StoryVolumeOverlayView
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel
import org.thoughtcrime.securesms.stories.viewer.StoryVolumeViewModel
import org.thoughtcrime.securesms.stories.viewer.info.StoryInfoBottomSheetDialogFragment
@@ -109,6 +110,7 @@ class StoryViewerPageFragment :
private lateinit var sendingBar: View
private lateinit var storyNormalBottomGradient: View
private lateinit var storyCaptionBottomGradient: View
private lateinit var addToGroupStoryButton: MaterialButton
private lateinit var callback: Callback
@@ -176,6 +178,7 @@ class StoryViewerPageFragment :
val storyGradientTop: View = view.findViewById(R.id.story_gradient_top)
val storyGradientBottom: View = view.findViewById(R.id.story_bottom_gradient_container)
val storyVolumeOverlayView: StoryVolumeOverlayView = view.findViewById(R.id.story_volume_overlay)
val addToGroupStoryButtonWrapper: View = view.findViewById(R.id.add_wrapper)
storyNormalBottomGradient = view.findViewById(R.id.story_gradient_bottom)
storyCaptionBottomGradient = view.findViewById(R.id.story_caption_gradient)
@@ -187,6 +190,7 @@ class StoryViewerPageFragment :
viewsAndReplies = view.findViewById(R.id.views_and_replies_bar)
sendingBarTextView = view.findViewById(R.id.sending_text_view)
sendingBar = view.findViewById(R.id.sending_bar)
addToGroupStoryButton = view.findViewById(R.id.add)
storySlate.callback = this
@@ -202,7 +206,8 @@ class StoryViewerPageFragment :
progressBar,
storyGradientTop,
storyGradientBottom,
storyCaptionContainer
storyCaptionContainer,
addToGroupStoryButtonWrapper
)
senderAvatar.setFallbackPhotoProvider(FallbackPhotoProvider())
@@ -212,6 +217,11 @@ class StoryViewerPageFragment :
requireActivity().onBackPressed()
}
val addToGroupStoryDelegate = AddToGroupStoryDelegate(this)
addToGroupStoryButton.setOnClickListener {
addToGroupStoryDelegate.addToStory(storyViewerPageArgs.recipientId)
}
val singleTapHandler = SingleTapHandler(
cardWrapper,
viewModel::goToNextPost,
@@ -384,6 +394,8 @@ class StoryViewerPageFragment :
if (state.posts.isNotEmpty() && state.selectedPostIndex in state.posts.indices) {
val post = state.posts[state.selectedPostIndex]
addToGroupStoryButton.visible = post.group != null
presentBottomBar(post, state.replyState, state.isReceiptsEnabled)
presentSenderAvatar(senderAvatar, post)
presentGroupAvatar(groupAvatar, post)