Implement Stories feature behind flag.

Co-Authored-By: Greyson Parrelli <37311915+greyson-signal@users.noreply.github.com>
Co-Authored-By: Rashad Sookram <95182499+rashad-signal@users.noreply.github.com>
This commit is contained in:
Alex Hart
2022-02-24 13:40:28 -04:00
parent 765185952e
commit 174cd860a0
416 changed files with 19506 additions and 857 deletions

View File

@@ -32,6 +32,7 @@ public class MediaSendActivityResult implements Parcelable {
private final TransportOption transport;
private final boolean viewOnce;
private final Collection<Mention> mentions;
private final boolean isStory;
public static @NonNull MediaSendActivityResult fromData(@NonNull Intent data) {
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivityResult.EXTRA_RESULT);
@@ -47,10 +48,11 @@ public class MediaSendActivityResult implements Parcelable {
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
@NonNull List<Mention> mentions,
boolean isStory)
{
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!");
return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions);
return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions, isStory);
}
public static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId,
@@ -58,10 +60,11 @@ public class MediaSendActivityResult implements Parcelable {
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
@NonNull List<Mention> mentions,
boolean isStory)
{
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!");
return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions);
return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions, isStory);
}
private MediaSendActivityResult(@NonNull RecipientId recipientId,
@@ -70,7 +73,8 @@ public class MediaSendActivityResult implements Parcelable {
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
@NonNull List<Mention> mentions,
boolean isStory)
{
this.recipientId = recipientId;
this.uploadResults = uploadResults;
@@ -79,6 +83,7 @@ public class MediaSendActivityResult implements Parcelable {
this.transport = transport;
this.viewOnce = viewOnce;
this.mentions = mentions;
this.isStory = isStory;
}
private MediaSendActivityResult(Parcel in) {
@@ -89,6 +94,7 @@ public class MediaSendActivityResult implements Parcelable {
this.transport = in.readParcelable(TransportOption.class.getClassLoader());
this.viewOnce = ParcelUtil.readBoolean(in);
this.mentions = ParcelUtil.readParcelableCollection(in, Mention.class);
this.isStory = ParcelUtil.readBoolean(in);
}
public @NonNull RecipientId getRecipientId() {
@@ -123,6 +129,10 @@ public class MediaSendActivityResult implements Parcelable {
return mentions;
}
public boolean isStory() {
return isStory;
}
public static final Creator<MediaSendActivityResult> CREATOR = new Creator<MediaSendActivityResult>() {
@Override
public MediaSendActivityResult createFromParcel(Parcel in) {
@@ -149,5 +159,6 @@ public class MediaSendActivityResult implements Parcelable {
dest.writeParcelable(transport, 0);
ParcelUtil.writeBoolean(dest, viewOnce);
ParcelUtil.writeParcelableCollection(dest, mentions);
ParcelUtil.writeBoolean(dest, isStory);
}
}

View File

@@ -7,6 +7,9 @@ sealed class HudCommand {
object StartCropAndRotate : HudCommand()
object SaveMedia : HudCommand()
object GoToText : HudCommand()
object GoToCapture : HudCommand()
object ResumeEntryTransition : HudCommand()
object OpenEmojiSearch : HudCommand()

View File

@@ -4,7 +4,10 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
@@ -17,11 +20,15 @@ import org.thoughtcrime.securesms.TransportOptions
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.Media
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.recipients.RecipientId
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
class MediaSelectionActivity :
PassphraseRequiredActivity(),
@@ -32,24 +39,46 @@ class MediaSelectionActivity :
lateinit var viewModel: MediaSelectionViewModel
private val textViewModel: TextStoryPostCreationViewModel by viewModels()
private val destination: MediaSelectionDestination
get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.fragment_container)
setContentView(R.layout.media_selection_activity)
val transportOption: TransportOption = requireNotNull(intent.getParcelableExtra(TRANSPORT_OPTION))
val initialMedia: List<Media> = intent.getParcelableArrayListExtra(MEDIA) ?: listOf()
val destination: MediaSelectionDestination = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
val message: CharSequence? = intent.getCharSequenceExtra(MESSAGE)
val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false)
val factory = MediaSelectionViewModel.Factory(destination, transportOption, initialMedia, message, isReply, MediaSelectionRepository(this))
viewModel = ViewModelProvider(this, factory)[MediaSelectionViewModel::class.java]
val textStoryToggle: ViewGroup = findViewById(R.id.switch_widget)
val textSwitch: View = findViewById(R.id.text_switch)
val cameraSwitch: View = findViewById(R.id.camera_switch)
textSwitch.setOnClickListener {
textSwitch.isSelected = true
cameraSwitch.isSelected = false
viewModel.sendCommand(HudCommand.GoToText)
}
cameraSwitch.setOnClickListener {
textSwitch.isSelected = false
cameraSwitch.isSelected = true
viewModel.sendCommand(HudCommand.GoToCapture)
}
if (savedInstanceState == null) {
cameraSwitch.isSelected = true
val navHostFragment = NavHostFragment.create(R.navigation.media)
supportFragmentManager
@@ -60,14 +89,33 @@ class MediaSelectionActivity :
navigateToStartDestination()
} else {
viewModel.onRestoreState(savedInstanceState)
textViewModel.restoreFromInstanceState(savedInstanceState)
}
(supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment).navController.addOnDestinationChangedListener { _, d, _ ->
when (d.id) {
R.id.mediaCaptureFragment -> textStoryToggle.visible = canDisplayStorySwitch()
R.id.textStoryPostCreationFragment -> textStoryToggle.visible = canDisplayStorySwitch()
else -> textStoryToggle.visible = false
}
}
onBackPressedDispatcher.addCallback(OnBackPressed())
}
private fun canDisplayStorySwitch(): Boolean {
return FeatureFlags.stories() &&
FeatureFlags.storiesTextPosts() &&
!SignalStore.storyValues().isFeatureDisabled &&
isCameraFirst() &&
!viewModel.hasSelectedMedia() &&
destination == MediaSelectionDestination.ChooseAfterMediaSelection
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewModel.onSaveState(outState)
textViewModel.saveToInstanceState(outState)
}
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.os.Bundle
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.recipients.RecipientId
sealed class MediaSelectionDestination {
@@ -28,7 +30,7 @@ sealed class MediaSelectionDestination {
}
class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() {
override fun getRecipientId(): RecipientId = id
override fun getRecipientSearchKey(): RecipientSearchKey = ContactSearchKey.KnownRecipient(id)
override fun toBundle(): Bundle {
return Bundle().apply {
@@ -38,7 +40,7 @@ sealed class MediaSelectionDestination {
}
class MultipleRecipients(val recipientIds: List<RecipientId>) : MediaSelectionDestination() {
override fun getRecipientIdList(): List<RecipientId> = recipientIds
override fun getRecipientSearchKeyList(): List<RecipientSearchKey> = recipientIds.map { ContactSearchKey.KnownRecipient(it) }
override fun toBundle(): Bundle {
return Bundle().apply {
@@ -47,8 +49,8 @@ sealed class MediaSelectionDestination {
}
}
open fun getRecipientId(): RecipientId? = null
open fun getRecipientIdList(): List<RecipientId> = emptyList()
open fun getRecipientSearchKey(): RecipientSearchKey? = null
open fun getRecipientSearchKeyList(): List<RecipientSearchKey> = emptyList()
abstract fun toBundle(): Bundle

View File

@@ -11,6 +11,8 @@ import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.imageeditor.core.model.EditorModel
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.Mention
@@ -31,12 +33,12 @@ import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult
import org.thoughtcrime.securesms.util.MessageUtil
import java.util.ArrayList
import java.util.Collections
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(MediaSelectionRepository::class.java)
@@ -68,12 +70,12 @@ class MediaSelectionRepository(context: Context) {
message: CharSequence?,
isSms: Boolean,
isViewOnce: Boolean,
singleRecipientId: RecipientId?,
recipientIds: List<RecipientId>,
singleContact: RecipientSearchKey?,
contacts: List<RecipientSearchKey>,
mentions: List<Mention>,
transport: TransportOption
): Maybe<MediaSendActivityResult> {
if (isSms && recipientIds.isNotEmpty()) {
if (isSms && contacts.isNotEmpty()) {
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
}
@@ -92,10 +94,10 @@ class MediaSelectionRepository(context: Context) {
Log.w(TAG, media.uri.toString() + " : " + media.transformProperties.transform { t: TransformProperties -> "" + t.isVideoTrim }.or("null"))
}
val singleRecipient = singleRecipientId?.let { Recipient.resolved(it) }
val singleRecipient: Recipient? = singleContact?.let { Recipient.resolved(it.recipientId) }
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions))
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions, false))
} else {
val splitMessage = MessageUtil.getSplitMessage(context, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize)
val splitBody = splitMessage.body
@@ -119,16 +121,15 @@ class MediaSelectionRepository(context: Context) {
uploadRepository.updateCaptions(updatedMedia)
uploadRepository.updateDisplayOrder(updatedMedia)
uploadRepository.getPreUploadResults { uploadResults ->
if (recipientIds.isNotEmpty()) {
val recipients = recipientIds.map { Recipient.resolved(it) }
sendMessages(recipients, splitBody, uploadResults, trimmedMentions, isViewOnce)
if (contacts.isNotEmpty()) {
sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce)
uploadRepository.deleteAbandonedAttachments()
emitter.onComplete()
} else if (uploadResults.isNotEmpty()) {
emitter.onSuccess(MediaSendActivityResult.forPreUpload(requireNotNull(singleRecipient).id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions))
emitter.onSuccess(MediaSendActivityResult.forPreUpload(singleRecipient!!.id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions, singleContact.isStory))
} else {
Log.w(TAG, "Got empty upload results! isSms: $isSms, updatedMedia.size(): ${updatedMedia.size}, isViewOnce: $isViewOnce, target: $singleRecipientId")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions))
Log.w(TAG, "Got empty upload results! isSms: $isSms, updatedMedia.size(): ${updatedMedia.size}, isViewOnce: $isViewOnce, target: $singleContact")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions, singleContact.isStory))
}
}
}
@@ -188,28 +189,57 @@ class MediaSelectionRepository(context: Context) {
}
@WorkerThread
private fun sendMessages(recipients: List<Recipient>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
val messages: MutableList<OutgoingSecureMediaMessage> = ArrayList(recipients.size)
private fun sendMessages(contacts: List<RecipientSearchKey>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
val broadcastMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
val storyMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
for (recipient in recipients) {
for (contact in contacts) {
val recipient = Recipient.resolved(contact.recipientId)
val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList
val message = OutgoingMediaMessage(
recipient,
body, emptyList(),
body,
emptyList(),
System.currentTimeMillis(),
-1,
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
isViewOnce,
ThreadDatabase.DistributionTypes.DEFAULT,
null, emptyList(), emptyList(),
mentions, mutableSetOf(), mutableSetOf()
isStory,
null,
null,
emptyList(),
emptyList(),
mentions,
mutableSetOf(),
mutableSetOf()
)
messages.add(OutgoingSecureMediaMessage(message))
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
if (isStory && preUploadResults.size > 1) {
preUploadResults.forEach {
val list = storyMessages[it] ?: mutableListOf()
list.add(OutgoingSecureMediaMessage(message))
storyMessages[it] = list
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
} else {
broadcastMessages.add(OutgoingSecureMediaMessage(message))
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
}
MessageSender.sendMediaBroadcast(context, messages, preUploadResults)
storyMessages.forEach { (preUploadResult, messages) ->
MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult))
}
if (broadcastMessages.isNotEmpty()) {
MessageSender.sendMediaBroadcast(context, broadcastMessages, preUploadResults)
}
}
}

View File

@@ -12,13 +12,13 @@ import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.Util
@@ -67,9 +67,9 @@ class MediaSelectionViewModel(
private var lastMediaDrag: Pair<Int, Int> = Pair(0, 0)
init {
val recipientId = destination.getRecipientId()
if (recipientId != null) {
store.update(Recipient.live(recipientId).liveData) { r, s ->
val recipientSearchKey = destination.getRecipientSearchKey()
if (recipientSearchKey != null) {
store.update(Recipient.live(recipientSearchKey.recipientId).liveData) { r, s ->
s.copy(
recipient = r,
isPreUploadEnabled = shouldPreUpload(s.isMeteredConnection, s.transportOption.isSms, r)
@@ -278,7 +278,7 @@ class MediaSelectionViewModel(
}
fun send(
selectedRecipientIds: List<RecipientId> = emptyList(),
selectedContacts: List<RecipientSearchKey> = emptyList(),
): Maybe<MediaSendActivityResult> {
return repository.send(
store.state.selectedMedia,
@@ -287,8 +287,8 @@ class MediaSelectionViewModel(
store.state.message,
store.state.transportOption.isSms,
isViewOnceEnabled(),
destination.getRecipientId(),
if (selectedRecipientIds.isNotEmpty()) selectedRecipientIds else destination.getRecipientIdList(),
destination.getRecipientSearchKey(),
if (selectedContacts.isNotEmpty()) selectedContacts else destination.getRecipientSearchKeyList(),
MentionAnnotation.getMentionsFromAnnotations(store.state.message),
store.state.transportOption
)
@@ -332,6 +332,10 @@ class MediaSelectionViewModel(
outState.putParcelableArrayList(STATE_EDITORS, ArrayList(editorStates))
}
fun hasSelectedMedia(): Boolean {
return store.state.selectedMedia.isNotEmpty()
}
fun onRestoreState(savedInstanceState: Bundle) {
val selection: List<Media> = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList()
val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED)

View File

@@ -14,11 +14,14 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.libsignal.util.guava.Optional
import java.io.FileDescriptor
@@ -40,6 +43,8 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
private lateinit var captureChildFragment: CameraFragment
private lateinit var navigator: MediaSelectionNavigator
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
captureChildFragment = CameraFragment.newInstance() as CameraFragment
@@ -75,6 +80,13 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
captureChildFragment.presentHud(state.selectedMedia.size)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += sharedViewModel.hudCommands.subscribe { command ->
if (command == HudCommand.GoToText) {
findNavController().safeNavigate(R.id.action_mediaCaptureFragment_to_textStoryPostCreationFragment)
}
}
if (isFirst()) {
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,

View File

@@ -129,7 +129,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
}
private fun initializeMentions() {
val recipientId: RecipientId = viewModel.destination.getRecipientId() ?: return
val recipientId: RecipientId = viewModel.destination.getRecipientSearchKey()?.recipientId ?: return
mentionsContainer = requireView().findViewById(R.id.mentions_picker_container)

View File

@@ -23,6 +23,8 @@ import androidx.viewpager2.widget.ViewPager2
import app.cash.exhaustive.Exhaustive
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
@@ -35,7 +37,6 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -135,14 +136,15 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
}
setFragmentResultListener(MultiselectForwardFragment.RESULT_SELECTION) { _, bundle ->
val recipientIds: List<RecipientId> = requireNotNull(bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS))
performSend(recipientIds)
val parcelizedKeys: List<ContactSearchKey.ParcelableContactSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS)!!
val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() }
performSend(contactSearchKeys)
}
sendButton.setOnClickListener {
if (sharedViewModel.isContactSelectionRequired) {
val args = MultiselectForwardFragmentArgs(false, title = R.string.MediaReviewFragment__send_to)
MultiselectForwardFragment.show(parentFragmentManager, args)
MultiselectForwardFragment.showFullScreen(parentFragmentManager, args)
} else {
performSend()
}
@@ -248,7 +250,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
}
}
private fun performSend(selection: List<RecipientId> = listOf()) {
private fun performSend(selection: List<ContactSearchKey> = listOf()) {
progressWrapper.visible = true
progressWrapper.animate()
.setStartDelay(300)
@@ -256,7 +258,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
.alpha(1f)
sharedViewModel
.send(selection)
.send(selection.filterIsInstance(RecipientSearchKey::class.java))
.subscribe(
{ result -> callback.onSentWithResult(result) },
{ error -> callback.onSendError(error) },

View File

@@ -0,0 +1,155 @@
package org.thoughtcrime.securesms.mediasend.v2.stories
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.util.FeatureFlags
class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
companion object {
const val GROUP_STORY = "group-story"
const val RESULT_SET = "groups"
}
private lateinit var confirmButton: View
private lateinit var selectedList: RecyclerView
private lateinit var backgroundHelper: View
private lateinit var divider: View
private lateinit var mediator: ContactSearchMediator
private var animatorSet: AnimatorSet? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)).inflate(R.layout.stories_choose_group_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.minimumHeight = resources.displayMetrics.heightPixels
val container = view.parent.parent.parent as FrameLayout
val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.stories_choose_group_bottom_bar, container, true)
confirmButton = bottomBar.findViewById(R.id.share_confirm)
selectedList = bottomBar.findViewById(R.id.selected_list)
backgroundHelper = bottomBar.findViewById(R.id.background_helper)
divider = bottomBar.findViewById(R.id.divider)
val adapter = ShareSelectionAdapter()
selectedList.adapter = adapter
confirmButton.setOnClickListener {
onDone()
}
val contactRecycler: RecyclerView = view.findViewById(R.id.contact_recycler)
mediator = ContactSearchMediator(
this,
contactRecycler,
FeatureFlags.shareSelectionLimit()
) { state ->
ContactSearchConfiguration.build {
query = state.query
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = false,
returnAsGroupStories = true
)
)
}
}
mediator.getSelectionState().observe(viewLifecycleOwner) { state ->
adapter.submitList(
state.filterIsInstance(ContactSearchKey.Story::class.java)
.map { it.recipientId }
.mapIndexed { index, recipientId ->
ShareSelectionMappingModel(
ShareContact(recipientId),
index == 0
)
}
)
if (state.isEmpty()) {
animateOutBottomBar()
} else {
animateInBottomBar()
}
}
val searchField: EditText = view.findViewById(R.id.search_field)
searchField.doAfterTextChanged {
mediator.onFilterChanged(it?.toString())
}
}
override fun onDestroyView() {
super.onDestroyView()
animatorSet?.cancel()
}
private fun animateInBottomBar() {
animatorSet?.cancel()
animatorSet = AnimatorSet().apply {
playTogether(
ObjectAnimator.ofFloat(confirmButton, View.ALPHA, 1f),
ObjectAnimator.ofFloat(selectedList, View.TRANSLATION_Y, 0f),
ObjectAnimator.ofFloat(backgroundHelper, View.TRANSLATION_Y, 0f),
ObjectAnimator.ofFloat(divider, View.TRANSLATION_Y, 0f)
)
start()
}
}
private fun animateOutBottomBar() {
val translationY = DimensionUnit.DP.toPixels(48f)
animatorSet?.cancel()
animatorSet = AnimatorSet().apply {
playTogether(
ObjectAnimator.ofFloat(confirmButton, View.ALPHA, 0f),
ObjectAnimator.ofFloat(selectedList, View.TRANSLATION_Y, translationY),
ObjectAnimator.ofFloat(backgroundHelper, View.TRANSLATION_Y, translationY),
ObjectAnimator.ofFloat(divider, View.TRANSLATION_Y, translationY)
)
start()
}
}
private fun onDone() {
setFragmentResult(
GROUP_STORY,
Bundle().apply {
putParcelableArrayList(
RESULT_SET,
ArrayList(
mediator.getSelectedContacts()
.filterIsInstance(ContactSearchKey.Story::class.java)
.map { it.recipientId }
)
)
}
)
dismissAllowingStateLoss()
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.mediasend.v2.stories
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
import org.thoughtcrime.securesms.util.fragments.findListener
class ChooseStoryTypeBottomSheet : DSLSettingsBottomSheetFragment(
layoutId = R.layout.dsl_settings_bottom_sheet_no_handle
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
LargeIconClickPreference.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ChooseStoryTypeBottomSheet__choose_your_story_type,
DSLSettingsText.CenterModifier, DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
)
)
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(
stringId = R.string.ChooseStoryTypeBottomSheet__new_private_story
),
summary = DSLSettingsText.from(
stringId = R.string.ChooseStoryTypeBottomSheet__visible_only_to
),
icon = DSLSettingsIcon.from(
R.drawable.ic_plus_24,
R.color.core_grey_15,
R.drawable.circle_tintable,
R.color.core_grey_80,
DimensionUnit.DP.toPixels(8f).toInt()
),
onClick = {
dismissAllowingStateLoss()
findListener<Callback>()?.onNewStoryClicked()
}
)
)
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(
stringId = R.string.ChooseStoryTypeBottomSheet__group_story
),
summary = DSLSettingsText.from(
stringId = R.string.ChooseStoryTypeBottomSheet__share_to_an_existing_group
),
icon = DSLSettingsIcon.from(
R.drawable.ic_group_outline_24,
R.color.core_grey_15,
R.drawable.circle_tintable,
R.color.core_grey_80,
DimensionUnit.DP.toPixels(8f).toInt()
),
onClick = {
dismissAllowingStateLoss()
findListener<Callback>()?.onGroupStoryClicked()
}
)
)
}
}
interface Callback {
fun onNewStoryClicked()
fun onGroupStoryClicked()
}
}

View File

@@ -0,0 +1,151 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.TypedValue
import androidx.core.view.doOnNextLayout
import org.signal.core.util.DimensionUnit
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.components.emoji.EmojiEditText
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class AutoSizeEmojiEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : EmojiEditText(context, attrs) {
private val maxTextSize = DimensionUnit.DP.toPixels(32f)
private val minTextSize = DimensionUnit.DP.toPixels(6f)
private var lowerBounds = minTextSize
private var upperBounds = maxTextSize
private val sizeSet: MutableSet<Float> = mutableSetOf()
private var beforeText: String? = null
private var beforeCursorPosition = 0
private val watcher: TextWatcher = object : TextWatcher {
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
beforeText = s.toString()
beforeCursorPosition = start
}
override fun afterTextChanged(s: Editable) {
if (lineCount == 0) {
doOnNextLayout {
checkCountAndAddListener()
}
} else {
checkCountAndAddListener()
}
}
}
init {
EditTextUtil.addGraphemeClusterLimitFilter(this, 700)
addTextChangedListener(watcher)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (isInEditMode) return
if (checkCountAndAddListener()) {
// TODO [stories] infinite measure loop when font change pushes us over the line count limit
measure(widthMeasureSpec, heightMeasureSpec)
return
}
try {
val operation = getNextAutoSizeOperation()
val newSize = when (operation) {
AutoSizeOperation.INCREASE -> {
lowerBounds = textSize
val midpoint = abs(lowerBounds - upperBounds) / 2f + lowerBounds
min(maxTextSize, midpoint)
}
AutoSizeOperation.DECREASE -> {
upperBounds = textSize
val midpoint = abs(lowerBounds - upperBounds) / 2f + lowerBounds
max(minTextSize, midpoint)
}
AutoSizeOperation.NONE -> return
}
if (abs(upperBounds - lowerBounds) < 1f) {
setTextSize(TypedValue.COMPLEX_UNIT_PX, lowerBounds)
return
} else if (sizeSet.add(newSize) || operation == AutoSizeOperation.INCREASE) {
setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize)
measure(widthMeasureSpec, heightMeasureSpec)
} else {
return
}
} finally {
upperBounds = maxTextSize
lowerBounds = minTextSize
sizeSet.clear()
}
}
private fun getNextAutoSizeOperation(): AutoSizeOperation {
if (lineCount == 0) {
return AutoSizeOperation.NONE
}
val availableHeight = measuredHeight - paddingTop - paddingBottom
if (availableHeight <= 0) {
return AutoSizeOperation.NONE
}
val pixelsRequired = lineHeight * lineCount
return if (pixelsRequired > availableHeight) {
if (textSize > minTextSize) {
AutoSizeOperation.DECREASE
} else {
AutoSizeOperation.NONE
}
} else if (pixelsRequired < availableHeight) {
if (textSize < maxTextSize) {
AutoSizeOperation.INCREASE
} else {
AutoSizeOperation.NONE
}
} else {
AutoSizeOperation.NONE
}
}
private fun checkCountAndAddListener(): Boolean {
removeTextChangedListener(watcher)
if (lineCount > 12) {
setText(beforeText)
setSelection(beforeCursorPosition)
addTextChangedListener(watcher)
return true
}
if (getNextAutoSizeOperation() != AutoSizeOperation.NONE) {
requestLayout()
}
addTextChangedListener(watcher)
return false
}
private enum class AutoSizeOperation {
INCREASE,
DECREASE,
NONE
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.view.Gravity
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
enum class TextAlignment(val gravity: Int, @DrawableRes val icon: Int) {
START(Gravity.START or Gravity.CENTER_VERTICAL, R.drawable.ic_text_start),
CENTER(Gravity.CENTER, R.drawable.ic_text_center),
END(Gravity.END or Gravity.CENTER_VERTICAL, R.drawable.ic_text_end);
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.util.next
typealias OnTextAlignmentChanged = (TextAlignment) -> Unit
/**
* Allows the user to toggle between START / END / CENTER alignment for text in a story post.
*/
class TextAlignmentButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private var textAlignment: TextAlignment = TextAlignment.CENTER
var onAlignmentChangedListener: OnTextAlignmentChanged? = null
init {
setImageResource(textAlignment.icon)
super.setOnClickListener {
setAlignment(textAlignment.next())
}
}
override fun setOnClickListener(l: OnClickListener?) {
throw UnsupportedOperationException()
}
fun setAlignment(textAlignment: TextAlignment) {
if (textAlignment != this.textAlignment) {
this.textAlignment = textAlignment
setImageResource(textAlignment.icon)
onAlignmentChangedListener?.invoke(textAlignment)
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
enum class TextColorStyle(@DrawableRes val icon: Int) {
/**
* Transparent background.
*/
NO_BACKGROUND(R.drawable.ic_text_normal),
/**
* White background, textColor foreground.
*/
NORMAL(R.drawable.ic_text_effect),
/**
* textColor background with white foreground.
*/
INVERT(R.drawable.ic_text_effect);
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.util.next
typealias OnTextColorStyleChanged = (TextColorStyle) -> Unit
/**
* Allows the user to cycle between text and background styling for a text post
*/
class TextColorStyleButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private var textColorStyle: TextColorStyle = TextColorStyle.NO_BACKGROUND
var onTextColorStyleChanged: OnTextColorStyleChanged? = null
init {
setImageResource(textColorStyle.icon)
super.setOnClickListener {
setTextColorStyle(textColorStyle.next())
}
}
override fun setOnClickListener(l: OnClickListener?) {
throw UnsupportedOperationException()
}
fun setTextColorStyle(textColorStyle: TextColorStyle) {
if (textColorStyle != this.textColorStyle) {
this.textColorStyle = textColorStyle
setImageResource(textColorStyle.icon)
onTextColorStyleChanged?.invoke(textColorStyle)
}
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.util.next
typealias OnTextFontChanged = (TextFont) -> Unit
/**
* Allows the user to cycle between fonts for a story text post
*/
class TextFontButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private var textFont: TextFont = TextFont.REGULAR
var onTextFontChanged: OnTextFontChanged? = null
init {
setImageResource(textFont.icon)
super.setOnClickListener {
setTextFont(textFont.next())
}
}
override fun setOnClickListener(l: OnClickListener?) {
throw UnsupportedOperationException()
}
fun setTextFont(textFont: TextFont) {
if (textFont != this.textFont) {
this.textFont = textFont
setImageResource(textFont.icon)
onTextFontChanged?.invoke(textFont)
}
}
}

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import org.thoughtcrime.securesms.conversation.colors.ChatColors
object TextStoryBackgroundColors {
private val backgroundColors: List<ChatColors> = listOf(
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 191.41f,
colors = intArrayOf(0xFFF53844.toInt(), 0xFFF33845.toInt(), 0xFFEC3848.toInt(), 0xFFE2384C.toInt(), 0xFFD63851.toInt(), 0xFFC73857.toInt(), 0xFFB6385E.toInt(), 0xFFA43866.toInt(), 0xFF93376D.toInt(), 0xFF813775.toInt(), 0xFF70377C.toInt(), 0xFF613782.toInt(), 0xFF553787.toInt(), 0xFF4B378B.toInt(), 0xFF44378E.toInt(), 0xFF42378F.toInt()),
positions = floatArrayOf(0.2109f, 0.2168f, 0.2339f, 0.2611f, 0.2975f, 0.3418f, 0.3932f, 0.4506f, 0.5129f, 0.5791f, 0.6481f, 0.719f, 0.7907f, 0.8621f, 0.9322f, 1.0f)
)
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 192.04f,
colors = intArrayOf(0xFFF04CE6.toInt(), 0xFFEE4BE6.toInt(), 0xFFE54AE5.toInt(), 0xFFD949E5.toInt(), 0xFFC946E4.toInt(), 0xFFB644E3.toInt(), 0xFFA141E3.toInt(), 0xFF8B3FE2.toInt(), 0xFF743CE1.toInt(), 0xFF5E39E0.toInt(), 0xFF4936DF.toInt(), 0xFF3634DE.toInt(), 0xFF2632DD.toInt(), 0xFF1930DD.toInt(), 0xFF112FDD.toInt(), 0xFF0E2FDD.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
),
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 175.46f,
colors = intArrayOf(0xFFFFC044.toInt(), 0xFFFE5C38.toInt()),
positions = floatArrayOf(0f, 1f)
)
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 180f,
colors = intArrayOf(0xFF0093E9.toInt(), 0xFF0294E9.toInt(), 0xFF0696E7.toInt(), 0xFF0D99E5.toInt(), 0xFF169EE3.toInt(), 0xFF21A3E0.toInt(), 0xFF2DA8DD.toInt(), 0xFF3AAEDA.toInt(), 0xFF46B5D6.toInt(), 0xFF53BBD3.toInt(), 0xFF5FC0D0.toInt(), 0xFF6AC5CD.toInt(), 0xFF73CACB.toInt(), 0xFF7ACDC9.toInt(), 0xFF7ECFC7.toInt(), 0xFF80D0C7.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
)
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 180f,
colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF64CDAB.toInt(), 0xFF60CBA8.toInt(), 0xFF5BC8A3.toInt(), 0xFF55C49D.toInt(), 0xFF4DC096.toInt(), 0xFF45BB8F.toInt(), 0xFF3CB687.toInt(), 0xFF33B17F.toInt(), 0xFF2AAC76.toInt(), 0xFF21A76F.toInt(), 0xFF1AA268.toInt(), 0xFF139F62.toInt(), 0xFF0E9C5E.toInt(), 0xFF0B9A5B.toInt(), 0xFF0A995A.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
)
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFFFC153.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFCCBD33.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFF84712E.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFF09B37B.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFF8B8BF9.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFF5151F6.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFF76E6E.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFC84641.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFC6C4A5.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFFA49595.toInt()
),
ChatColors.forColor(
id = ChatColors.Id.NotSet,
color = 0xFF292929.toInt()
),
)
fun getInitialBackgroundColor(): ChatColors = backgroundColors.first()
fun cycleBackgroundColor(chatColors: ChatColors): ChatColors {
val indexOfNextColor = (backgroundColors.indexOf(chatColors) + 1) % backgroundColors.size
return backgroundColors[indexOfNextColor]
}
}

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.drawToBitmap
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.stories.StoryTextPostView
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creation_fragment), TextStoryPostTextEntryFragment.Callback {
private lateinit var scene: ConstraintLayout
private lateinit var linkButton: View
private lateinit var backgroundButton: AppCompatImageView
private lateinit var send: View
private lateinit var storyTextPostView: StoryTextPostView
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private val viewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private val linkPreviewViewModel: LinkPreviewViewModel by viewModels(
ownerProducer = {
requireActivity()
},
factoryProducer = {
LinkPreviewViewModel.Factory(LinkPreviewRepository())
}
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
scene = view.findViewById(R.id.scene)
linkButton = view.findViewById(R.id.add_link)
backgroundButton = view.findViewById(R.id.background_selector)
send = view.findViewById(R.id.send)
storyTextPostView = view.findViewById(R.id.story_text_post)
storyTextPostView.showCloseButton()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += sharedViewModel.hudCommands.subscribe {
if (it == HudCommand.GoToCapture) {
findNavController().popBackStack()
}
}
viewModel.typeface.observe(viewLifecycleOwner) { typeface ->
storyTextPostView.setTypeface(typeface)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
backgroundButton.background = state.backgroundColor.chatBubbleMask
storyTextPostView.bindFromCreationState(state)
if (state.linkPreviewUri != null) {
linkPreviewViewModel.onTextChanged(requireContext(), state.linkPreviewUri, 0, state.linkPreviewUri.lastIndex)
} else {
linkPreviewViewModel.onSend()
}
val canSend = state.body.isNotEmpty() || !state.linkPreviewUri.isNullOrEmpty()
send.alpha = if (canSend) 1f else 0.5f
send.isEnabled = canSend
}
linkPreviewViewModel.linkPreviewState.observe(viewLifecycleOwner) { state ->
storyTextPostView.bindLinkPreviewState(state, View.GONE)
storyTextPostView.postAdjustLinkPreviewTranslationY()
}
storyTextPostView.setTextViewClickListener {
storyTextPostView.hidePostContent()
TextStoryPostTextEntryFragment().show(childFragmentManager, null)
}
backgroundButton.setOnClickListener {
viewModel.cycleBackgroundColor()
}
linkButton.setOnClickListener {
TextStoryPostLinkEntryFragment().show(childFragmentManager, null)
}
storyTextPostView.setLinkPreviewCloseListener {
viewModel.setLinkPreview("")
}
send.setOnClickListener {
storyTextPostView.hideCloseButton()
viewModel.setBitmap(storyTextPostView.drawToBitmap())
findNavController().safeNavigate(R.id.action_textStoryPostCreationFragment_to_textStoryPostSendFragment)
}
}
override fun onResume() {
super.onResume()
storyTextPostView.showCloseButton()
}
override fun onTextStoryPostTextEntryDismissed() {
storyTextPostView.showPostContent()
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.graphics.Color
import android.os.Parcelable
import androidx.annotation.ColorInt
import androidx.annotation.IntRange
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.scribbles.HSVColorSlider
import org.thoughtcrime.securesms.util.FeatureFlags
@Parcelize
data class TextStoryPostCreationState(
val body: CharSequence = "",
val textColor: Int = HSVColorSlider.getLastColor(),
val textColorStyle: TextColorStyle = TextColorStyle.NO_BACKGROUND,
val textAlignment: TextAlignment = if (FeatureFlags.storiesTextFunctions()) TextAlignment.START else TextAlignment.CENTER,
val textSize: Float = DimensionUnit.DP.toPixels(32f),
val textFont: TextFont = TextFont.REGULAR,
@IntRange(from = 0, to = 100) val textScale: Int = 50,
val backgroundColor: ChatColors = TextStoryBackgroundColors.getInitialBackgroundColor(),
val linkPreviewUri: String? = null,
) : Parcelable {
@ColorInt
@IgnoredOnParcel
val textForegroundColor: Int = when (textColorStyle) {
TextColorStyle.NO_BACKGROUND -> textColor
TextColorStyle.NORMAL -> textColor
TextColorStyle.INVERT -> Color.WHITE
}
@ColorInt
@IgnoredOnParcel
val textBackgroundColor: Int = when (textColorStyle) {
TextColorStyle.NO_BACKGROUND -> Color.TRANSPARENT
TextColorStyle.NORMAL -> Color.WHITE
TextColorStyle.INVERT -> textColor
}
}

View File

@@ -0,0 +1,149 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Bundle
import androidx.annotation.ColorInt
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.fonts.Fonts
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.util.FutureTaskListener
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Locale
import java.util.concurrent.ExecutionException
class TextStoryPostCreationViewModel : ViewModel() {
private val store = Store(TextStoryPostCreationState())
private val textFontSubject: Subject<TextFont> = BehaviorSubject.create()
private val disposables = CompositeDisposable()
private val internalThumbnail = MutableLiveData<Bitmap>()
val thumbnail: LiveData<Bitmap> = internalThumbnail
private val internalTypeface = MutableLiveData<Typeface>()
val state: LiveData<TextStoryPostCreationState> = store.stateLiveData
val typeface: LiveData<Typeface> = internalTypeface
init {
textFontSubject.onNext(store.state.textFont)
textFontSubject
.observeOn(Schedulers.io())
.distinctUntilChanged()
.map { Fonts.resolveFont(ApplicationDependencies.getApplication(), Locale.getDefault(), it) }
.switchMap {
when (it) {
is Fonts.FontResult.Async -> asyncFontEmitter(it)
is Fonts.FontResult.Immediate -> Observable.just(it.typeface)
}
}
.subscribeOn(Schedulers.io())
.subscribe {
internalTypeface.postValue(it)
}
}
fun setBitmap(bitmap: Bitmap) {
internalThumbnail.value?.recycle()
internalThumbnail.value = bitmap
}
private fun asyncFontEmitter(async: Fonts.FontResult.Async): Observable<Typeface> {
return Observable.create {
it.onNext(async.placeholder)
val listener = object : FutureTaskListener<Typeface> {
override fun onSuccess(result: Typeface) {
it.onNext(result)
it.onComplete()
}
override fun onFailure(exception: ExecutionException?) {
Log.w(TAG, "Failed to load remote font.", exception)
it.onComplete()
}
}
it.setCancellable {
async.future.removeListener(listener)
}
async.future.addListener(listener)
}
}
override fun onCleared() {
disposables.clear()
thumbnail.value?.recycle()
}
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.getParcelable(TEXT_STORY_INSTANCE_STATE)!!
textFontSubject.onNext(store.state.textFont)
store.update { state }
}
}
fun getBody(): CharSequence {
return store.state.body
}
@ColorInt
fun getTextColor(): Int {
return store.state.textColor
}
fun setTextColor(@ColorInt textColor: Int) {
store.update { it.copy(textColor = textColor) }
}
fun setBody(body: CharSequence) {
store.update { it.copy(body = body) }
}
fun setAlignment(textAlignment: TextAlignment) {
store.update { it.copy(textAlignment = textAlignment) }
}
fun setTextScale(scale: Int) {
store.update { it.copy(textScale = scale) }
}
fun setTextColorStyle(textColorStyle: TextColorStyle) {
store.update { it.copy(textColorStyle = textColorStyle) }
}
fun setTextFont(textFont: TextFont) {
textFontSubject.onNext(textFont)
store.update { it.copy(textFont = textFont) }
}
fun cycleBackgroundColor() {
store.update { it.copy(backgroundColor = TextStoryBackgroundColors.cycleBackgroundColor(it.backgroundColor)) }
}
fun setLinkPreview(url: String) {
store.update { it.copy(linkPreviewUri = url) }
}
companion object {
private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java)
private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state"
}
}

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.widget.EditText
import androidx.constraintlayout.widget.Group
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.stories.StoryLinkPreviewView
import org.thoughtcrime.securesms.util.visible
class TextStoryPostLinkEntryFragment : KeyboardEntryDialogFragment(
contentLayoutId = R.layout.stories_text_post_link_entry_fragment
) {
private val linkPreviewViewModel: LinkPreviewViewModel by viewModels(
factoryProducer = { LinkPreviewViewModel.Factory(LinkPreviewRepository()) }
)
private val viewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val input: EditText = view.findViewById(R.id.input)
val linkPreview: StoryLinkPreviewView = view.findViewById(R.id.link_preview)
val confirmButton: View = view.findViewById(R.id.confirm_button)
val shareALinkGroup: Group = view.findViewById(R.id.share_a_link_group)
input.addTextChangedListener(
afterTextChanged = {
linkPreviewViewModel.onTextChanged(requireContext(), it!!.toString(), input.selectionStart, input.selectionEnd)
}
)
confirmButton.setOnClickListener {
if (linkPreviewViewModel.hasLinkPreview()) {
viewModel.setLinkPreview(linkPreviewViewModel.linkPreviewState.value!!.linkPreview.get().url)
}
dismissAllowingStateLoss()
}
linkPreviewViewModel.linkPreviewState.observe(viewLifecycleOwner) { state ->
linkPreview.bind(state)
shareALinkGroup.visible = !state.isLoading && !state.linkPreview.isPresent && state.error == null
confirmButton.isEnabled = state.linkPreview.isPresent
}
}
override fun onDismiss(dialog: DialogInterface) {
linkPreviewViewModel.onSend()
super.onDismiss(dialog)
}
}

View File

@@ -0,0 +1,328 @@
package org.thoughtcrime.securesms.mediasend.v2.text
import android.animation.Animator
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Color
import android.os.Bundle
import android.text.InputFilter
import android.text.Spanned
import android.text.TextUtils
import android.view.MotionEvent
import android.view.View
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.SeekBar
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatSeekBar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.updateLayoutParams
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.viewModels
import androidx.transition.TransitionManager
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations
import org.thoughtcrime.securesms.scribbles.HSVColorSlider
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.getColor
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setColor
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.findListener
import java.util.Locale
/**
* Allows user to enter and style the text of a text-based story post
*/
class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment(
contentLayoutId = R.layout.stories_text_post_text_entry_fragment
) {
private val viewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private lateinit var scene: ConstraintLayout
private lateinit var input: EditText
private lateinit var confirmButton: View
private lateinit var colorBar: AppCompatSeekBar
private lateinit var colorIndicator: ImageView
private lateinit var alignmentButton: TextAlignmentButton
private lateinit var scaleBar: AppCompatSeekBar
private lateinit var backgroundButton: TextColorStyleButton
private lateinit var fontButton: TextFontButton
private lateinit var fadeableViews: List<View>
private var colorIndicatorAlphaAnimator: Animator? = null
private var bufferFilter = BufferFilter()
private var allCapsFilter = InputFilter.AllCaps()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initializeViews(view)
initializeInput()
initializeAlignmentButton()
initializeColorBar()
initializeConfirmButton()
initializeWidthBar()
initializeBackgroundButton()
initializeFontButton()
initializeViewModel()
view.setOnClickListener { dismissAllowingStateLoss() }
}
private fun initializeViews(view: View) {
scene = view.findViewById(R.id.scene)
input = view.findViewById(R.id.input)
confirmButton = view.findViewById(R.id.confirm)
colorBar = view.findViewById(R.id.color_bar)
colorIndicator = view.findViewById(R.id.color_indicator)
alignmentButton = view.findViewById(R.id.alignment_button)
fontButton = view.findViewById(R.id.font_button)
scaleBar = view.findViewById(R.id.width_bar)
backgroundButton = view.findViewById(R.id.background_button)
fadeableViews = listOf(
confirmButton,
fontButton,
backgroundButton
)
if (FeatureFlags.storiesTextFunctions()) {
fadeableViews = fadeableViews + alignmentButton
alignmentButton.visibility = View.VISIBLE
scaleBar.visibility = View.VISIBLE
}
}
private fun initializeInput() {
input.filters = input.filters + bufferFilter
input.doOnTextChanged { _, _, _, _ ->
presentHint()
}
input.setText(viewModel.getBody())
}
private fun presentHint() {
if (TextUtils.isEmpty(input.text)) {
if (input.filters.contains(allCapsFilter)) {
input.hint = getString(R.string.TextStoryPostTextEntryFragment__add_text).toUpperCase(Locale.getDefault())
} else {
input.setHint(R.string.TextStoryPostTextEntryFragment__add_text)
}
} else {
input.hint = ""
}
}
private fun initializeBackgroundButton() {
backgroundButton.onTextColorStyleChanged = {
viewModel.setTextColorStyle(it)
}
}
private fun initializeFontButton() {
fontButton.onTextFontChanged = {
viewModel.setTextFont(it)
}
}
private fun initializeColorBar() {
colorIndicator.background = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_color_preview)
colorBar.setUpForColor(
thumbBorderColor = Color.WHITE,
onColorChanged = {
colorIndicator.drawable.colorFilter = SimpleColorFilter(colorBar.getColor())
colorIndicator.translationX = (colorBar.thumb.bounds.left.toFloat() + ViewUtil.dpToPx(16))
viewModel.setTextColor(colorBar.getColor())
},
onDragStart = {
colorIndicatorAlphaAnimator?.end()
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 1f)
colorIndicatorAlphaAnimator?.duration = 150L
colorIndicatorAlphaAnimator?.start()
TransitionManager.endTransitions(scene)
val constraintSet = ConstraintSet()
constraintSet.clone(scene)
fadeableViews.forEach {
constraintSet.setVisibility(it.id, ConstraintSet.INVISIBLE)
}
constraintSet.applyTo(scene)
TransitionManager.beginDelayedTransition(scene)
constraintSet.connect(colorBar.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
constraintSet.connect(colorBar.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
constraintSet.applyTo(scene)
},
onDragEnd = {
colorIndicatorAlphaAnimator?.end()
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 0f)
colorIndicatorAlphaAnimator?.duration = 150L
colorIndicatorAlphaAnimator?.start()
TransitionManager.endTransitions(scene)
TransitionManager.beginDelayedTransition(scene)
val constraintSet = ConstraintSet()
constraintSet.clone(scene)
fadeableViews.forEach {
constraintSet.setVisibility(it.id, ConstraintSet.VISIBLE)
}
constraintSet.connect(colorBar.id, ConstraintSet.START, backgroundButton.id, ConstraintSet.END)
constraintSet.connect(colorBar.id, ConstraintSet.END, fontButton.id, ConstraintSet.START)
constraintSet.applyTo(scene)
}
)
colorBar.setColor(viewModel.getTextColor())
}
private fun initializeConfirmButton() {
confirmButton.setOnClickListener {
dismissAllowingStateLoss()
}
}
private fun initializeAlignmentButton() {
alignmentButton.onAlignmentChangedListener = { alignment ->
viewModel.setAlignment(alignment)
}
}
private fun initializeViewModel() {
viewModel.typeface.observe(viewLifecycleOwner) { typeface ->
input.typeface = typeface
}
viewModel.state.observe(viewLifecycleOwner) { state ->
input.setTextColor(state.textForegroundColor)
input.setHintTextColor(state.textForegroundColor)
if (state.textBackgroundColor == Color.TRANSPARENT) {
input.background = null
} else {
input.background = AppCompatResources.getDrawable(requireContext(), R.drawable.rounded_rectangle_secondary_18)?.apply {
colorFilter = SimpleColorFilter(state.textBackgroundColor)
}
}
alignmentButton.setAlignment(state.textAlignment)
scaleBar.progress = state.textScale
val scale = TextStoryScale.convertToScale(state.textScale)
input.scaleX = scale
input.scaleY = scale
input.gravity = state.textAlignment.gravity
input.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = state.textAlignment.gravity
}
if (state.textFont.isAllCaps && !input.filters.contains(allCapsFilter)) {
input.filters = input.filters + allCapsFilter
val selectionStart = input.selectionStart
val selectionEnd = input.selectionEnd
val text = bufferFilter.text
bufferFilter.text = ""
input.setText(text)
input.setSelection(selectionStart, selectionEnd)
} else if (!state.textFont.isAllCaps && input.filters.contains(allCapsFilter)) {
input.filters = (input.filters.toList() - allCapsFilter).toTypedArray()
val selectionStart = input.selectionStart
val selectionEnd = input.selectionEnd
val text = bufferFilter.text
bufferFilter.text = ""
input.setText(text)
input.setSelection(selectionStart, selectionEnd)
}
backgroundButton.setTextColorStyle(state.textColorStyle)
fontButton.setTextFont(state.textFont)
}
}
@SuppressLint("ClickableViewAccessibility")
private fun initializeWidthBar() {
scaleBar.progressDrawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_width_slider_bg)
scaleBar.thumb = HSVColorSlider.createThumbDrawable(Color.WHITE)
scaleBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
viewModel.setTextScale(progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
})
scaleBar.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
animateWidthBarIn()
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
animateWidthBarOut()
}
v.onTouchEvent(event)
}
}
private fun animateWidthBarIn() {
scaleBar.animate()
.setDuration(250L)
.setInterpolator(MediaAnimations.interpolator)
.translationX(ViewUtil.dpToPx(36).toFloat())
}
private fun animateWidthBarOut() {
scaleBar.animate()
.setDuration(250L)
.setInterpolator(MediaAnimations.interpolator)
.translationX(0f)
}
override fun onResume() {
super.onResume()
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(input)
}
override fun onPause() {
super.onPause()
ViewUtil.hideKeyboard(requireContext(), input)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
viewModel.setBody(bufferFilter.text)
findListener<Callback>()?.onTextStoryPostTextEntryDismissed()
}
interface Callback {
fun onTextStoryPostTextEntryDismissed()
}
/**
* BufferFilter records the input to a text field such that a later filter can capitalize text without the buffer
* being modified.
*/
class BufferFilter : InputFilter {
var text: CharSequence = ""
override fun filter(source: CharSequence?, start: Int, end: Int, dest: Spanned?, dstart: Int, dend: Int): CharSequence? {
text = if (source.isNullOrEmpty()) {
text.removeRange(dstart, dend)
} else {
text.replaceRange(dstart, dend, source.subSequence(start, end))
}
return null
}
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.mediasend.v2.text
object TextStoryScale {
fun convertToScale(textScale: Int): Float {
if (textScale < 0) {
return 1f
}
val minimumScale = 0.5f
val maximumScale = 1.5f
val scaleRange = maximumScale - minimumScale
val percent = textScale / 100f
val scale = scaleRange * percent + minimumScale
return scale
}
}

View File

@@ -0,0 +1,220 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import androidx.appcompat.widget.Toolbar
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback {
private lateinit var shareListWrapper: View
private lateinit var shareSelectionRecyclerView: RecyclerView
private lateinit var shareConfirmButton: View
private val shareSelectionAdapter = ShareSelectionAdapter()
private val disposables = LifecycleDisposable()
private lateinit var contactSearchMediator: ContactSearchMediator
private val viewModel: TextStoryPostSendViewModel by viewModels(
factoryProducer = {
TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository())
}
)
private val creationViewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private val linkPreviewViewModel: LinkPreviewViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val viewPort: ImageView = view.findViewById(R.id.preview_viewport)
val searchField: EditText = view.findViewById(R.id.search_field)
toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
shareListWrapper = view.findViewById(R.id.list_wrapper)
shareConfirmButton = view.findViewById(R.id.share_confirm)
shareSelectionRecyclerView = view.findViewById(R.id.selected_list)
shareSelectionRecyclerView.adapter = shareSelectionAdapter
disposables.bindTo(viewLifecycleOwner)
creationViewModel.thumbnail.observe(viewLifecycleOwner) { bitmap ->
viewPort.setImageBitmap(bitmap)
}
shareConfirmButton.setOnClickListener {
if (viewModel.isFirstSendToAStory(contactSearchMediator.getSelectedContacts())) {
StoryDialogs.guardWithAddToYourStoryDialog(
context = requireContext(),
onAddToStory = { send() },
onEditViewers = {
viewModel.onSendCancelled()
HideStoryFromDialogFragment().show(childFragmentManager, null)
},
onCancel = {
viewModel.onSendCancelled()
}
)
} else {
send()
}
}
searchField.doAfterTextChanged {
contactSearchMediator.onFilterChanged(it?.toString())
}
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!!
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId)))
contactSearchMediator.onFilterChanged("")
}
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
val groups: Set<RecipientId> = bundle.getParcelableArrayList<RecipientId>(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet()
val keys: Set<ContactSearchKey.Story> = groups.map { ContactSearchKey.Story(it) }.toSet()
contactSearchMediator.addToVisibleGroupStories(keys)
contactSearchMediator.onFilterChanged("")
contactSearchMediator.setKeysSelected(keys)
}
val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container)
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit()) { contactSearchState ->
ContactSearchConfiguration.build {
query = contactSearchState.query
addSection(
ContactSearchConfiguration.Section.Stories(
groupStories = contactSearchState.groupStories,
includeHeader = true,
headerAction = getHeaderAction(),
expandConfig = ContactSearchConfiguration.ExpandConfig(
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES)
)
)
)
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeHeader = true
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeHeader = true,
transportType = ContactSearchConfiguration.TransportType.PUSH,
includeSelf = true
)
)
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = true
)
)
}
}
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { selection ->
shareSelectionAdapter.submitList(selection.mapIndexed { index, contact -> ShareSelectionMappingModel(contact.requireShareContact(), index == 0) })
if (selection.isNotEmpty()) {
animateInSelection()
} else {
animateOutSelection()
}
}
val saveStateAndSelection = LiveDataUtil.combineLatest(viewModel.state, contactSearchMediator.getSelectionState(), ::Pair)
saveStateAndSelection.observe(viewLifecycleOwner) { (state, selection) ->
when (state) {
TextStoryPostSendState.INIT -> shareConfirmButton.isEnabled = selection.isNotEmpty()
TextStoryPostSendState.SENDING -> shareConfirmButton.isEnabled = false
TextStoryPostSendState.SENT -> requireActivity().finish()
}
}
}
private fun send() {
shareConfirmButton.isEnabled = false
val textStoryPostCreationState = creationViewModel.state.value
val linkPreviewState = linkPreviewViewModel.linkPreviewState.value
viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewState!!)
}
private fun animateInSelection() {
shareListWrapper.animate()
.alpha(1f)
.translationY(0f)
shareConfirmButton.animate()
.alpha(1f)
}
private fun animateOutSelection() {
shareListWrapper.animate()
.alpha(0f)
.translationY(DimensionUnit.DP.toPixels(48f))
shareConfirmButton.animate()
.alpha(0f)
}
private fun getHeaderAction(): HeaderAction {
return HeaderAction(
R.string.ContactsCursorLoader_new_story,
R.drawable.ic_plus_20
) {
ChooseStoryTypeBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
override fun onNewStoryClicked() {
findNavController().navigate(R.id.action_textStoryPostSendFragment_to_newStory)
}
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
import io.reactivex.rxjava3.core.Completable
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
class TextStoryPostSendRepository {
fun isFirstSendToStory(shareContacts: Set<ContactSearchKey>): Boolean {
if (SignalStore.storyValues().userHasAddedToAStory) {
return false
}
return shareContacts.any { it is ContactSearchKey.Story }
}
fun send(contactSearchKey: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Completable {
// TODO [stories] -- Implementation once we know what text post messages look like.
return Completable.complete()
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
enum class TextStoryPostSendState {
INIT,
SENDING,
SENT
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
import org.thoughtcrime.securesms.util.livedata.Store
class TextStoryPostSendViewModel(private val repository: TextStoryPostSendRepository) : ViewModel() {
private val store = Store(TextStoryPostSendState.INIT)
private val disposables = CompositeDisposable()
val state: LiveData<TextStoryPostSendState> = store.stateLiveData
override fun onCleared() {
disposables.clear()
}
fun isFirstSendToAStory(contactSearchKeys: Set<ContactSearchKey>): Boolean {
store.update {
TextStoryPostSendState.SENDING
}
return repository.isFirstSendToStory(contactSearchKeys)
}
fun onSendCancelled() {
store.update {
TextStoryPostSendState.INIT
}
}
fun onSend(contactSearchKeys: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreviewState: LinkPreviewViewModel.LinkPreviewState) {
store.update {
TextStoryPostSendState.SENDING
}
disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreviewState.linkPreview.orNull()).subscribeBy(
onComplete = {
store.update { TextStoryPostSendState.SENT }
},
onError = {
// TODO [stories] -- Error of some sort.
store.update { TextStoryPostSendState.INIT }
}
)
}
class Factory(private val repository: TextStoryPostSendRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(TextStoryPostSendViewModel(repository)) as T
}
}
}