mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-27 12:15:50 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.text.send
|
||||
|
||||
enum class TextStoryPostSendState {
|
||||
INIT,
|
||||
SENDING,
|
||||
SENT
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user