Implement first-time-nav screen for stories.

This commit is contained in:
Alex Hart
2022-06-24 15:34:17 -03:00
committed by Cody Henthorne
parent 858c7a7f2e
commit 521bd2cce4
16 changed files with 2840 additions and 103 deletions

View File

@@ -34,11 +34,22 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
* Cannot send to story tooltip marker
*/
private const val CANNOT_SEND_SEEN_MARKER = "stories.cannot.send.video.tooltip.seen"
/**
* Whether or not the user has see the "Navigation education" view
*/
private const val USER_HAS_SEEN_FIRST_NAV_VIEW = "stories.user.has.seen.first.navigation.view"
}
override fun onFirstEverAppLaunch() = Unit
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY, VIDEO_TOOLTIP_SEEN_MARKER, CANNOT_SEND_SEEN_MARKER)
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
MANUAL_FEATURE_DISABLE,
USER_HAS_ADDED_TO_A_STORY,
VIDEO_TOOLTIP_SEEN_MARKER,
CANNOT_SEND_SEEN_MARKER,
USER_HAS_SEEN_FIRST_NAV_VIEW
)
var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false)
@@ -50,6 +61,8 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
var cannotSendTooltipSeen by booleanValue(CANNOT_SEND_SEEN_MARKER, false)
var userHasSeenFirstNavView: Boolean by booleanValue(USER_HAS_SEEN_FIRST_NAV_VIEW, false)
fun setLatestStorySend(storySend: StorySend) {
synchronized(this) {
val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)

View File

@@ -0,0 +1,120 @@
package org.thoughtcrime.securesms.stories
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.visible
class StoryFirstTimeNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {
companion object {
private const val BLUR_ALPHA = 0x3D
private const val NO_BLUR_ALPHA = 0xB3
}
init {
inflate(context, R.layout.story_first_time_navigation_view, this)
}
private val blurHashView: ImageView = findViewById(R.id.edu_blur_hash)
private val overlayView: ImageView = findViewById(R.id.edu_overlay)
private val gotIt: View = findViewById(R.id.edu_got_it)
private val cornerMask = CornerMask(this).apply {
setRadius(DimensionUnit.DP.toPixels(18f).toInt())
}
var callback: Callback? = null
init {
if (isRenderEffectSupported()) {
blurHashView.visible = false
overlayView.visible = true
overlayView.setImageDrawable(ColorDrawable(Color.argb(BLUR_ALPHA, 0, 0, 0)))
}
gotIt.setOnClickListener {
callback?.onGotItClicked()
GlideApp.with(this).clear(blurHashView)
blurHashView.setImageDrawable(null)
hide()
}
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
cornerMask.mask(canvas)
}
fun setBlurHash(blurHash: BlurHash?) {
if (isRenderEffectSupported() || callback?.userHasSeenFirstNavigationView() == true) {
return
}
if (blurHash == null) {
blurHashView.visible = false
overlayView.visible = true
overlayView.setImageDrawable(ColorDrawable(Color.argb(NO_BLUR_ALPHA, 0, 0, 0)))
GlideApp.with(this).clear(blurHashView)
return
} else {
blurHashView.visible = true
overlayView.visible = true
overlayView.setImageDrawable(ColorDrawable(Color.argb(BLUR_ALPHA, 0, 0, 0)))
}
GlideApp.with(this)
.load(blurHash)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
setBlurHash(null)
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
return false
}
})
.into(blurHashView)
}
fun show() {
if (callback?.userHasSeenFirstNavigationView() == true) {
return
}
visible = true
}
fun hide() {
visible = false
}
private fun isRenderEffectSupported(): Boolean {
return Build.VERSION.SDK_INT >= 31
}
interface Callback {
fun userHasSeenFirstNavigationView(): Boolean
fun onGotItClicked()
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.stories.viewer.page
import android.view.ViewStub
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.stories.StoryFirstTimeNavigationView
import org.thoughtcrime.securesms.util.views.Stub
/**
* Specialized stub that allows for early arrival of the blurhash and callback.
*/
class StoryFirstNavigationStub(viewStub: ViewStub) : Stub<StoryFirstTimeNavigationView>(viewStub) {
private var callback: StoryFirstTimeNavigationView.Callback? = null
private var blurHash: BlurHash? = null
fun setCallback(callback: StoryFirstTimeNavigationView.Callback) {
if (resolved()) {
get().callback = callback
} else {
this.callback = callback
}
}
fun setBlurHash(blurHash: BlurHash?) {
if (resolved()) {
get().setBlurHash(blurHash)
} else {
this.blurHash = blurHash
}
}
override fun get(): StoryFirstTimeNavigationView {
val needsResolve = !resolved()
val view = super.get()
if (needsResolve) {
view.setBlurHash(blurHash)
view.callback = callback
blurHash = null
callback = null
}
return view
}
}

View File

@@ -4,8 +4,11 @@ import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.RenderEffect
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.GestureDetector
import android.view.MotionEvent
@@ -20,6 +23,7 @@ import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.animation.PathInterpolatorCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -42,11 +46,13 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment
import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.StoryFirstTimeNavigationView
import org.thoughtcrime.securesms.stories.StorySlateView
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.stories.viewer.StoryViewerState
@@ -78,7 +84,8 @@ class StoryViewerPageFragment :
MultiselectForwardBottomSheet.Callback,
StorySlateView.Callback,
StoryTextPostPreviewFragment.Callback,
StoriesSharedElementCrossFaderView.Callback {
StoriesSharedElementCrossFaderView.Callback,
StoryFirstTimeNavigationView.Callback {
private lateinit var progressBar: SegmentedProgressBar
private lateinit var storySlate: StorySlateView
@@ -87,6 +94,7 @@ class StoryViewerPageFragment :
private lateinit var blurContainer: ImageView
private lateinit var storyCaptionContainer: FrameLayout
private lateinit var storyContentContainer: FrameLayout
private lateinit var storyFirstTimeNavigationViewStub: StoryFirstNavigationStub
private lateinit var callback: Callback
@@ -150,9 +158,11 @@ class StoryViewerPageFragment :
progressBar = view.findViewById(R.id.progress)
viewsAndReplies = view.findViewById(R.id.views_and_replies_bar)
storyCrossfader = view.findViewById(R.id.story_content_crossfader)
storyFirstTimeNavigationViewStub = StoryFirstNavigationStub(view.findViewById(R.id.story_first_time_nav_stub))
storySlate.callback = this
storyCrossfader.callback = this
storyFirstTimeNavigationViewStub.setCallback(this)
chrome = listOf(
closeView,
@@ -165,7 +175,7 @@ class StoryViewerPageFragment :
viewsAndReplies,
progressBar,
storyGradientTop,
storyGradientBottom
storyGradientBottom,
)
senderAvatar.setFallbackPhotoProvider(FallbackPhotoProvider())
@@ -338,20 +348,37 @@ class StoryViewerPageFragment :
resumeProgress()
}
val wasDisplayingNavigationView = isFirstTimeNavVisible()
when {
state.hideChromeImmediate -> {
hideChromeImmediate()
storyCaptionContainer.visible = false
storyFirstTimeNavigationViewStub.takeIf { it.resolved() }?.get()?.hide()
}
state.hideChrome -> {
hideChrome()
storyCaptionContainer.visible = true
storyFirstTimeNavigationViewStub.get().show()
}
else -> {
showChrome()
storyCaptionContainer.visible = true
storyFirstTimeNavigationViewStub.get().show()
}
}
val isDisplayingNavigationView = isFirstTimeNavVisible()
if (isDisplayingNavigationView && Build.VERSION.SDK_INT >= 31) {
hideChromeImmediate()
storyContentContainer.setRenderEffect(RenderEffect.createBlurEffect(100f, 100f, Shader.TileMode.CLAMP))
} else if (Build.VERSION.SDK_INT >= 31) {
storyContentContainer.setRenderEffect(null)
}
if (wasDisplayingNavigationView xor isDisplayingNavigationView) {
viewModel.setIsDisplayingFirstTimeNavigation(isFirstTimeNavVisible())
}
}
timeoutDisposable.bindTo(viewLifecycleOwner)
@@ -402,6 +429,10 @@ class StoryViewerPageFragment :
viewModel.setIsDisplayingForwardDialog(false)
}
private fun isFirstTimeNavVisible(): Boolean {
return storyFirstTimeNavigationViewStub.takeIf { it.resolved() }?.get()?.isVisible ?: false
}
private fun calculateDurationForText(textContent: StoryPost.Content.TextContent): Long {
val divisionsOf15 = textContent.length / CHARACTERS_PER_SECOND
return TimeUnit.SECONDS.toMillis(divisionsOf15) + MIN_TEXT_STORY_PLAYBACK
@@ -623,6 +654,8 @@ class StoryViewerPageFragment :
val record = storyPost.conversationMessage.messageRecord as? MediaMmsMessageRecord
val blurHash = record?.slideDeck?.thumbnailSlide?.placeholderBlur
storyFirstTimeNavigationViewStub.setBlurHash(blurHash)
if (blurHash == null) {
GlideApp.with(blur).clear(blur)
} else {
@@ -1035,4 +1068,13 @@ class StoryViewerPageFragment :
fun onFinishedPosts(recipientId: RecipientId)
fun onStoryHidden(recipientId: RecipientId)
}
override fun userHasSeenFirstNavigationView(): Boolean {
return SignalStore.storyValues().userHasSeenFirstNavView
}
override fun onGotItClicked() {
SignalStore.storyValues().userHasSeenFirstNavView = true
viewModel.setIsDisplayingFirstTimeNavigation(false)
}
}

View File

@@ -214,6 +214,10 @@ class StoryViewerPageViewModel(
storyViewerPlaybackStore.update { it.copy(isRunningSharedElementAnimation = isRunningSharedElementAnimation) }
}
fun setIsDisplayingFirstTimeNavigation(isDisplayingFirstTimeNavigation: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingFirstTimeNavigation = isDisplayingFirstTimeNavigation) }
}
private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int): StoryViewerPageState.ReplyState {
if (index !in state.posts.indices) {
return StoryViewerPageState.ReplyState.NONE

View File

@@ -15,7 +15,8 @@ data class StoryViewerPlaybackState(
val isFragmentResumed: Boolean = false,
val isDisplayingLinkPreviewTooltip: Boolean = false,
val isDisplayingReactionAnimation: Boolean = false,
val isRunningSharedElementAnimation: Boolean = false
val isRunningSharedElementAnimation: Boolean = false,
val isDisplayingFirstTimeNavigation: Boolean = false
) {
val hideChromeImmediate: Boolean = isRunningSharedElementAnimation
@@ -36,5 +37,6 @@ data class StoryViewerPlaybackState(
!isFragmentResumed ||
isDisplayingLinkPreviewTooltip ||
isDisplayingReactionAnimation ||
isRunningSharedElementAnimation
isRunningSharedElementAnimation ||
isDisplayingFirstTimeNavigation
}