mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 00:59:49 +01:00
Implement first-time-nav screen for stories.
This commit is contained in:
committed by
Cody Henthorne
parent
858c7a7f2e
commit
521bd2cce4
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user