mirror of
https://github.com/fork-maintainers/iceraven-browser
synced 2024-11-03 23:15:31 +00:00
[fenix] EXP 2991: Add surface to messaging fml (https://github.com/mozilla-mobile/fenix/pull/28423)
* Move messaging fml to a separate file * Add surface property to message data * Get messages for just a single surface * Add surface to messaging middleware * ktlint * Add tests for filtering by surface * Add homescreen to default-browser message * Move surface param to MessageActions instead of MessagingMiddleware * Added computed property for surface to message * ktlint * Address reviewer comment * Fixup tests Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
parent
f22bdeef5d
commit
5ac1cd9b48
@ -207,7 +207,9 @@ class Components(private val context: Context) {
|
||||
core.pocketStoriesService,
|
||||
context.pocketStoriesSelectedCategoriesDataStore,
|
||||
),
|
||||
MessagingMiddleware(messagingStorage = analytics.messagingStorage),
|
||||
MessagingMiddleware(
|
||||
messagingStorage = analytics.messagingStorage,
|
||||
),
|
||||
MetricsMiddleware(metrics = analytics.metrics),
|
||||
),
|
||||
)
|
||||
|
@ -22,6 +22,7 @@ import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
|
||||
import org.mozilla.fenix.home.recenttabs.RecentTab
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
|
||||
import org.mozilla.fenix.library.history.PendingDeletionHistory
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.wallpapers.Wallpaper
|
||||
|
||||
/**
|
||||
@ -137,7 +138,7 @@ sealed class AppAction : Action {
|
||||
/**
|
||||
* Evaluates if a new messages should be shown to users.
|
||||
*/
|
||||
object Evaluate : MessagingAction()
|
||||
data class Evaluate(val surface: MessageSurfaceId) : MessagingAction()
|
||||
|
||||
/**
|
||||
* Updates [MessagingState.messageToShow] with the given [message].
|
||||
@ -147,7 +148,7 @@ sealed class AppAction : Action {
|
||||
/**
|
||||
* Updates [MessagingState.messageToShow] with the given [message].
|
||||
*/
|
||||
object ConsumeMessageToShow : MessagingAction()
|
||||
data class ConsumeMessageToShow(val surface: MessageSurfaceId) : MessagingAction()
|
||||
|
||||
/**
|
||||
* Updates [MessagingState.messages] with the given [messages].
|
||||
|
@ -5,6 +5,7 @@
|
||||
package org.mozilla.fenix.gleanplumb
|
||||
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
/**
|
||||
@ -33,6 +34,9 @@ data class Message(
|
||||
val priority: Int
|
||||
get() = style.priority
|
||||
|
||||
val surface: MessageSurfaceId
|
||||
get() = data.surface
|
||||
|
||||
/**
|
||||
* A data class that holds metadata that help to identify if a message should shown.
|
||||
*
|
||||
|
@ -7,6 +7,7 @@ package org.mozilla.fenix.gleanplumb
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import org.mozilla.fenix.components.AppStore
|
||||
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
|
||||
/**
|
||||
* A message observer that updates the provided.
|
||||
@ -14,7 +15,7 @@ import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
|
||||
class MessagingFeature(val appStore: AppStore) : LifecycleAwareFeature {
|
||||
|
||||
override fun start() {
|
||||
appStore.dispatch(MessagingAction.Evaluate)
|
||||
appStore.dispatch(MessagingAction.Evaluate(MessageSurfaceId.HOMESCREEN))
|
||||
}
|
||||
|
||||
override fun stop() = Unit
|
||||
|
@ -4,6 +4,8 @@
|
||||
|
||||
package org.mozilla.fenix.gleanplumb
|
||||
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
|
||||
/**
|
||||
* Represent all the state related to the Messaging framework.
|
||||
* @param messages Indicates all the available messages.
|
||||
@ -12,5 +14,5 @@ package org.mozilla.fenix.gleanplumb
|
||||
*/
|
||||
data class MessagingState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val messageToShow: Message? = null,
|
||||
val messageToShow: Map<MessageSurfaceId, Message> = emptyMap(),
|
||||
)
|
||||
|
@ -13,6 +13,7 @@ import org.mozilla.experiments.nimbus.GleanPlumbMessageHelper
|
||||
import org.mozilla.experiments.nimbus.internal.FeatureHolder
|
||||
import org.mozilla.experiments.nimbus.internal.NimbusException
|
||||
import org.mozilla.fenix.nimbus.ControlMessageBehavior
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.Messaging
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
@ -63,34 +64,35 @@ class NimbusMessagingStorage(
|
||||
val defaultStyle = StyleData()
|
||||
val storageMetadata = metadataStorage.getMetadata()
|
||||
|
||||
return nimbusMessages.mapNotNull { (key, value) ->
|
||||
val action = sanitizeAction(key, value.action, nimbusActions, value.isControl) ?: return@mapNotNull null
|
||||
Message(
|
||||
id = key,
|
||||
data = value,
|
||||
action = action,
|
||||
style = nimbusStyles[value.style] ?: defaultStyle,
|
||||
metadata = storageMetadata[key] ?: addMetadata(key),
|
||||
triggers = sanitizeTriggers(key, value.trigger, nimbusTriggers)
|
||||
?: return@mapNotNull null,
|
||||
)
|
||||
}.filter {
|
||||
it.maxDisplayCount >= it.metadata.displayCount &&
|
||||
!it.metadata.dismissed &&
|
||||
!it.metadata.pressed
|
||||
}.sortedByDescending {
|
||||
it.style.priority
|
||||
}
|
||||
return nimbusMessages
|
||||
.mapNotNull { (key, value) ->
|
||||
val action = sanitizeAction(key, value.action, nimbusActions, value.isControl) ?: return@mapNotNull null
|
||||
Message(
|
||||
id = key,
|
||||
data = value,
|
||||
action = action,
|
||||
style = nimbusStyles[value.style] ?: defaultStyle,
|
||||
metadata = storageMetadata[key] ?: addMetadata(key),
|
||||
triggers = sanitizeTriggers(key, value.trigger, nimbusTriggers)
|
||||
?: return@mapNotNull null,
|
||||
)
|
||||
}.filter {
|
||||
it.maxDisplayCount >= it.metadata.displayCount &&
|
||||
!it.metadata.dismissed &&
|
||||
!it.metadata.pressed
|
||||
}.sortedByDescending {
|
||||
it.style.priority
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next higher priority message which all their triggers are true.
|
||||
*/
|
||||
fun getNextMessage(availableMessages: List<Message>): Message? {
|
||||
fun getNextMessage(surface: MessageSurfaceId, availableMessages: List<Message>): Message? {
|
||||
val jexlCache = HashMap<String, Boolean>()
|
||||
val helper = gleanPlumb.createMessageHelper(customAttributes)
|
||||
val message = availableMessages.firstOrNull {
|
||||
isMessageEligible(it, helper, jexlCache)
|
||||
surface == it.surface && isMessageEligible(it, helper, jexlCache)
|
||||
} ?: return null
|
||||
|
||||
// Check this isn't an experimental message. If not, we can go ahead and return it.
|
||||
|
@ -44,12 +44,15 @@ class MessagingMiddleware(
|
||||
}
|
||||
|
||||
is Evaluate -> {
|
||||
val message = messagingStorage.getNextMessage(context.state.messaging.messages)
|
||||
val message = messagingStorage.getNextMessage(
|
||||
action.surface,
|
||||
context.state.messaging.messages,
|
||||
)
|
||||
if (message != null) {
|
||||
context.dispatch(UpdateMessageToShow(message))
|
||||
onMessagedDisplayed(message, context)
|
||||
} else {
|
||||
context.dispatch(ConsumeMessageToShow)
|
||||
context.dispatch(ConsumeMessageToShow(action.surface))
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,8 +138,9 @@ class MessagingMiddleware(
|
||||
context: AppStoreMiddlewareContext,
|
||||
message: Message,
|
||||
) {
|
||||
if (context.state.messaging.messageToShow?.id == message.id) {
|
||||
context.dispatch(ConsumeMessageToShow)
|
||||
val current = context.state.messaging.messageToShow[message.surface]
|
||||
if (current?.id == message.id) {
|
||||
context.dispatch(ConsumeMessageToShow(message.surface))
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,7 +158,7 @@ class MessagingMiddleware(
|
||||
oldMessage: Message,
|
||||
updatedMessage: Message,
|
||||
): List<Message> {
|
||||
val actualMessageToShow = context.state.messaging.messageToShow
|
||||
val actualMessageToShow = context.state.messaging.messageToShow.get(updatedMessage.surface)
|
||||
|
||||
if (actualMessageToShow?.id == oldMessage.id) {
|
||||
context.dispatch(UpdateMessageToShow(updatedMessage))
|
||||
|
@ -17,9 +17,11 @@ import org.mozilla.fenix.gleanplumb.MessagingState
|
||||
internal object MessagingReducer {
|
||||
fun reduce(state: AppState, action: AppAction.MessagingAction): AppState = when (action) {
|
||||
is UpdateMessageToShow -> {
|
||||
val messageToShow = state.messaging.messageToShow.toMutableMap()
|
||||
messageToShow[action.message.surface] = action.message
|
||||
state.copy(
|
||||
messaging = state.messaging.copy(
|
||||
messageToShow = action.message,
|
||||
messageToShow = messageToShow,
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -31,9 +33,11 @@ internal object MessagingReducer {
|
||||
)
|
||||
}
|
||||
is ConsumeMessageToShow -> {
|
||||
val messageToShow = state.messaging.messageToShow.toMutableMap()
|
||||
messageToShow.remove(action.surface)
|
||||
state.copy(
|
||||
messaging = state.messaging.copy(
|
||||
messageToShow = null,
|
||||
messageToShow = messageToShow,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import org.mozilla.fenix.home.Mode
|
||||
import org.mozilla.fenix.home.OnboardingState
|
||||
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.onboarding.HomeCFRPresenter
|
||||
import org.mozilla.fenix.utils.Settings
|
||||
|
||||
@ -167,7 +168,7 @@ private fun AppState.toAdapterList(settings: Settings): List<AdapterItem> = when
|
||||
expandedCollections,
|
||||
recentBookmarks,
|
||||
showCollectionPlaceholder,
|
||||
messaging.messageToShow,
|
||||
messaging.messageToShow[MessageSurfaceId.HOMESCREEN],
|
||||
shouldShowRecentTabs(settings),
|
||||
shouldShowRecentSyncedTabs(settings),
|
||||
recentHistory,
|
||||
|
@ -20,8 +20,6 @@ import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
@ -34,6 +32,7 @@ import org.mozilla.fenix.components.appstate.AppState
|
||||
import org.mozilla.fenix.components.appstate.filterOut
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.ext.getFilteredStories
|
||||
import org.mozilla.fenix.gleanplumb.Message
|
||||
import org.mozilla.fenix.home.CurrentMode
|
||||
import org.mozilla.fenix.home.Mode
|
||||
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
|
||||
@ -45,6 +44,8 @@ import org.mozilla.fenix.home.recenttabs.RecentTab
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.onboarding.FenixOnboarding
|
||||
|
||||
class AppStoreTest {
|
||||
@ -113,11 +114,19 @@ class AppStoreTest {
|
||||
@Test
|
||||
fun `GIVEN a new value for messageToShow WHEN NimbusMessageChange is called THEN update the current value`() =
|
||||
runTest {
|
||||
assertNull(appStore.state.messaging.messageToShow)
|
||||
assertTrue(appStore.state.messaging.messageToShow.isEmpty())
|
||||
|
||||
appStore.dispatch(UpdateMessageToShow(mockk())).join()
|
||||
val message = Message(
|
||||
"message",
|
||||
MessageData(surface = MessageSurfaceId.HOMESCREEN),
|
||||
"action",
|
||||
mockk(),
|
||||
emptyList(),
|
||||
mockk(),
|
||||
)
|
||||
appStore.dispatch(UpdateMessageToShow(message)).join()
|
||||
|
||||
assertNotNull(appStore.state.messaging.messageToShow)
|
||||
assertFalse(appStore.state.messaging.messageToShow.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -12,6 +12,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mozilla.fenix.components.AppStore
|
||||
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
|
||||
class MessagingFeatureTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ -25,6 +26,6 @@ class MessagingFeatureTest {
|
||||
|
||||
binding.start()
|
||||
|
||||
verify { appStore.dispatch(MessagingAction.Evaluate) }
|
||||
verify { appStore.dispatch(MessagingAction.Evaluate(MessageSurfaceId.HOMESCREEN)) }
|
||||
}
|
||||
}
|
||||
|
@ -20,20 +20,19 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.experiments.nimbus.GleanPlumbInterface
|
||||
import org.mozilla.experiments.nimbus.GleanPlumbMessageHelper
|
||||
import org.mozilla.experiments.nimbus.NullVariables
|
||||
import org.mozilla.experiments.nimbus.Res
|
||||
import org.mozilla.experiments.nimbus.internal.FeatureHolder
|
||||
import org.mozilla.experiments.nimbus.internal.NimbusException
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.nimbus.ControlMessageBehavior.SHOW_NEXT_MESSAGE
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.Messaging
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class NimbusMessagingStorageTest {
|
||||
|
||||
private val activity: HomeActivity = mockk(relaxed = true)
|
||||
private val storageNimbus: NimbusMessagingStorage = mockk(relaxed = true)
|
||||
private lateinit var storage: NimbusMessagingStorage
|
||||
private lateinit var metadataStorage: MessageMetadataStorage
|
||||
private lateinit var gleanPlumb: GleanPlumbInterface
|
||||
@ -49,6 +48,7 @@ class NimbusMessagingStorageTest {
|
||||
gleanPlumb = mockk(relaxed = true)
|
||||
metadataStorage = mockk(relaxed = true)
|
||||
malformedWasReported = false
|
||||
NullVariables.instance.setContext(testContext)
|
||||
messagingFeature = createMessagingFeature()
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns mapOf("message-1" to Message.Metadata(id = "message-1"))
|
||||
@ -63,11 +63,14 @@ class NimbusMessagingStorageTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling getMessages THEN provide a list of available messages`() = runTest {
|
||||
val message = storage.getMessages().first()
|
||||
fun `WHEN calling getMessages THEN provide a list of available messages for a given surface`() = runTest {
|
||||
val homescreenMessage = storage.getMessages().first()
|
||||
|
||||
assertEquals("message-1", message.id)
|
||||
assertEquals("message-1", message.metadata.id)
|
||||
assertEquals("message-1", homescreenMessage.id)
|
||||
assertEquals("message-1", homescreenMessage.metadata.id)
|
||||
|
||||
val notificationMessage = storage.getMessages().last()
|
||||
assertEquals("message-2", notificationMessage.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -230,7 +233,7 @@ class NimbusMessagingStorageTest {
|
||||
|
||||
assertEquals("message-1", firstMessage.id)
|
||||
assertEquals("message-1", firstMessage.metadata.id)
|
||||
assertTrue(messages.size == 1)
|
||||
assertTrue(messages.size == 2)
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@ -490,7 +493,7 @@ class NimbusMessagingStorageTest {
|
||||
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns false
|
||||
|
||||
val result = spiedStorage.getNextMessage(listOf(message))
|
||||
val result = spiedStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, listOf(message))
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
@ -500,7 +503,7 @@ class NimbusMessagingStorageTest {
|
||||
val spiedStorage = spyk(storage)
|
||||
val message = Message(
|
||||
"same-id",
|
||||
mockk(relaxed = true),
|
||||
createMessageData(surface = MessageSurfaceId.HOMESCREEN),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
@ -510,7 +513,7 @@ class NimbusMessagingStorageTest {
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns true
|
||||
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns false
|
||||
|
||||
val result = spiedStorage.getNextMessage(listOf(message))
|
||||
val result = spiedStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, listOf(message))
|
||||
|
||||
assertEquals(message.id, result!!.id)
|
||||
}
|
||||
@ -518,9 +521,7 @@ class NimbusMessagingStorageTest {
|
||||
@Test
|
||||
fun `GIVEN a message under experiment WHEN calling getNextMessage THEN call recordExposure`() {
|
||||
val spiedStorage = spyk(storage)
|
||||
val messageData: MessageData = mockk(relaxed = true)
|
||||
|
||||
every { messageData.isControl } returns false
|
||||
val messageData: MessageData = createMessageData(isControl = false)
|
||||
|
||||
val message = Message(
|
||||
"same-id",
|
||||
@ -534,7 +535,7 @@ class NimbusMessagingStorageTest {
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns true
|
||||
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns true
|
||||
|
||||
val result = spiedStorage.getNextMessage(listOf(message))
|
||||
val result = spiedStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, listOf(message))
|
||||
|
||||
verify { messagingFeature.recordExposure() }
|
||||
assertEquals(message.id, result!!.id)
|
||||
@ -543,12 +544,10 @@ class NimbusMessagingStorageTest {
|
||||
@Test
|
||||
fun `GIVEN a control message WHEN calling getNextMessage THEN return the next eligible message`() {
|
||||
val spiedStorage = spyk(storage)
|
||||
val messageData: MessageData = mockk(relaxed = true)
|
||||
val controlMessageData: MessageData = mockk(relaxed = true)
|
||||
val messageData: MessageData = createMessageData()
|
||||
val controlMessageData: MessageData = createMessageData(isControl = true)
|
||||
|
||||
every { messageData.isControl } returns false
|
||||
every { spiedStorage.getOnControlBehavior() } returns SHOW_NEXT_MESSAGE
|
||||
every { controlMessageData.isControl } returns true
|
||||
|
||||
val message = Message(
|
||||
"id",
|
||||
@ -571,7 +570,10 @@ class NimbusMessagingStorageTest {
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns true
|
||||
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns true
|
||||
|
||||
val result = spiedStorage.getNextMessage(listOf(controlMessage, message))
|
||||
val result = spiedStorage.getNextMessage(
|
||||
MessageSurfaceId.HOMESCREEN,
|
||||
listOf(controlMessage, message),
|
||||
)
|
||||
|
||||
verify { messagingFeature.recordExposure() }
|
||||
assertEquals(message.id, result!!.id)
|
||||
@ -581,33 +583,35 @@ class NimbusMessagingStorageTest {
|
||||
action: String = "action-1",
|
||||
style: String = "style-1",
|
||||
triggers: List<String> = listOf("trigger-1"),
|
||||
): MessageData {
|
||||
val messageData1: MessageData = mockk(relaxed = true)
|
||||
every { messageData1.action } returns action
|
||||
every { messageData1.style } returns style
|
||||
every { messageData1.trigger } returns triggers
|
||||
return messageData1
|
||||
}
|
||||
surface: MessageSurfaceId = MessageSurfaceId.HOMESCREEN,
|
||||
isControl: Boolean = false,
|
||||
) = MessageData(
|
||||
action = Res.string(action),
|
||||
style = style,
|
||||
trigger = triggers,
|
||||
surface = surface,
|
||||
isControl = isControl,
|
||||
)
|
||||
|
||||
private fun createMessagingFeature(
|
||||
triggers: Map<String, String> = mapOf("trigger-1" to "trigger-1-expression"),
|
||||
styles: Map<String, StyleData> = mapOf("style-1" to createStyle()),
|
||||
actions: Map<String, String> = mapOf("action-1" to "action-1-url"),
|
||||
messages: Map<String, MessageData> = mapOf(
|
||||
"message-1" to createMessageData(),
|
||||
"malformed" to mockk(relaxed = true),
|
||||
"message-1" to createMessageData(surface = MessageSurfaceId.HOMESCREEN),
|
||||
"message-2" to createMessageData(surface = MessageSurfaceId.NOTIFICATION),
|
||||
"malformed" to createMessageData(action = "malformed-action"),
|
||||
),
|
||||
): FeatureHolder<Messaging> {
|
||||
val messagingFeature: FeatureHolder<Messaging> = mockk(relaxed = true)
|
||||
|
||||
messaging = mockk(relaxed = true)
|
||||
|
||||
every { messaging.triggers } returns triggers
|
||||
every { messaging.styles } returns styles
|
||||
every { messaging.actions } returns actions
|
||||
every { messaging.messages } returns messages
|
||||
|
||||
messaging = Messaging(
|
||||
actions = actions,
|
||||
triggers = triggers,
|
||||
messages = messages,
|
||||
styles = styles,
|
||||
)
|
||||
every { messagingFeature.value() } returns messaging
|
||||
|
||||
return messagingFeature
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,7 @@ import org.mozilla.fenix.gleanplumb.MessagingState
|
||||
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
@ -85,9 +86,9 @@ class MessagingMiddlewareTest {
|
||||
every { messagingState.messages } returns emptyList()
|
||||
every { appState.messaging } returns messagingState
|
||||
every { middlewareContext.state } returns appState
|
||||
every { messagingStorage.getNextMessage(any()) } returns message
|
||||
every { messagingStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, any()) } returns message
|
||||
|
||||
middleware.invoke(middlewareContext, {}, Evaluate)
|
||||
middleware.invoke(middlewareContext, {}, Evaluate(MessageSurfaceId.HOMESCREEN))
|
||||
|
||||
verify { middlewareContext.dispatch(UpdateMessageToShow(message)) }
|
||||
}
|
||||
@ -218,7 +219,7 @@ class MessagingMiddlewareTest {
|
||||
fun `WHEN consumeMessageToShowIfNeeded THEN consume the message`() = runTestOnMain {
|
||||
val message = Message(
|
||||
"control-id",
|
||||
mockk(relaxed = true),
|
||||
MessageData(),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
@ -227,20 +228,20 @@ class MessagingMiddlewareTest {
|
||||
val appState: AppState = mockk(relaxed = true)
|
||||
val messagingState: MessagingState = mockk(relaxed = true)
|
||||
|
||||
every { messagingState.messageToShow } returns message
|
||||
every { messagingState.messageToShow } returns mapOf(message.surface to message)
|
||||
every { appState.messaging } returns messagingState
|
||||
every { middlewareContext.state } returns appState
|
||||
|
||||
middleware.consumeMessageToShowIfNeeded(middlewareContext, message)
|
||||
|
||||
verify { middlewareContext.dispatch(ConsumeMessageToShow) }
|
||||
verify { middlewareContext.dispatch(ConsumeMessageToShow(message.surface)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN updateMessage THEN update available messages`() = runTestOnMain {
|
||||
val oldMessage = Message(
|
||||
"oldMessage",
|
||||
mockk(relaxed = true),
|
||||
MessageData(),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
@ -249,7 +250,7 @@ class MessagingMiddlewareTest {
|
||||
|
||||
val updatedMessage = Message(
|
||||
"oldMessage",
|
||||
mockk(relaxed = true),
|
||||
MessageData(),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
@ -261,7 +262,7 @@ class MessagingMiddlewareTest {
|
||||
val appState: AppState = mockk(relaxed = true)
|
||||
val messagingState: MessagingState = mockk(relaxed = true)
|
||||
|
||||
every { messagingState.messageToShow } returns oldMessage
|
||||
every { messagingState.messageToShow } returns mapOf(oldMessage.surface to oldMessage)
|
||||
every { appState.messaging } returns messagingState
|
||||
every { middlewareContext.state } returns appState
|
||||
every { spiedMiddleware.removeMessage(middlewareContext, oldMessage) } returns emptyList()
|
||||
|
@ -15,7 +15,11 @@ import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMes
|
||||
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
|
||||
import org.mozilla.fenix.components.appstate.AppState
|
||||
import org.mozilla.fenix.components.appstate.AppStoreReducer
|
||||
import org.mozilla.fenix.gleanplumb.Message
|
||||
import org.mozilla.fenix.gleanplumb.MessagingState
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
class MessagingReducerTest {
|
||||
|
||||
@ -23,22 +27,34 @@ class MessagingReducerTest {
|
||||
fun `GIVEN a new value for messageToShow WHEN UpdateMessageToShow is called THEN update the current value`() {
|
||||
val initialState = AppState(
|
||||
messaging = MessagingState(
|
||||
messageToShow = null,
|
||||
messageToShow = mapOf(),
|
||||
),
|
||||
)
|
||||
|
||||
val m = createMessage("message1")
|
||||
|
||||
var updatedState = MessagingReducer.reduce(
|
||||
initialState,
|
||||
UpdateMessageToShow(mockk()),
|
||||
UpdateMessageToShow(m),
|
||||
)
|
||||
|
||||
assertNotNull(updatedState.messaging.messageToShow)
|
||||
assertNotNull(updatedState.messaging.messageToShow[m.surface])
|
||||
|
||||
updatedState = AppStoreReducer.reduce(updatedState, ConsumeMessageToShow)
|
||||
updatedState = AppStoreReducer.reduce(updatedState, ConsumeMessageToShow(m.surface))
|
||||
|
||||
assertNull(updatedState.messaging.messageToShow)
|
||||
assertNull(updatedState.messaging.messageToShow[m.surface])
|
||||
}
|
||||
|
||||
private fun createMessage(id: String, action: String = "action-1", surface: MessageSurfaceId = MessageSurfaceId.HOMESCREEN): Message =
|
||||
Message(
|
||||
id = id,
|
||||
data = MessageData(surface = surface),
|
||||
action = action,
|
||||
style = StyleData(),
|
||||
triggers = listOf(),
|
||||
metadata = Message.Metadata(id = id),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN a new value for messages WHEN UpdateMessages is called THEN update the current value`() {
|
||||
val initialState = AppState(
|
||||
|
212
messaging.fml.yaml
Normal file
212
messaging.fml.yaml
Normal file
@ -0,0 +1,212 @@
|
||||
---
|
||||
features:
|
||||
messaging:
|
||||
description: |
|
||||
Configuration for the messaging system.
|
||||
|
||||
In practice this is a set of growable lookup tables for the
|
||||
message controller to piece together.
|
||||
|
||||
variables:
|
||||
message-under-experiment:
|
||||
description: Id or prefix of the message under experiment.
|
||||
type: Option<String>
|
||||
default: null
|
||||
|
||||
messages:
|
||||
description: A growable collection of messages
|
||||
type: Map<String, MessageData>
|
||||
default: {}
|
||||
|
||||
triggers:
|
||||
description: >
|
||||
A collection of out the box trigger
|
||||
expressions. Each entry maps to a
|
||||
valid JEXL expression.
|
||||
type: Map<String, String>
|
||||
default: {}
|
||||
styles:
|
||||
description: >
|
||||
A map of styles to configure message
|
||||
appearance.
|
||||
type: Map<String, StyleData>
|
||||
default: {}
|
||||
|
||||
actions:
|
||||
type: Map<String, String>
|
||||
description: A growable map of action URLs.
|
||||
default: {}
|
||||
on-control:
|
||||
type: ControlMessageBehavior
|
||||
description: What should be displayed when a control message is selected.
|
||||
default: show-next-message
|
||||
notification-config:
|
||||
description: Configuration of the notification worker for all notification messages.
|
||||
type: NotificationConfig
|
||||
default: {}
|
||||
defaults:
|
||||
- value:
|
||||
triggers:
|
||||
USER_RECENTLY_INSTALLED: days_since_install < 7
|
||||
USER_RECENTLY_UPDATED: days_since_update < 7 && days_since_install != days_since_update
|
||||
USER_TIER_ONE_COUNTRY: ('US' in locale || 'GB' in locale || 'CA' in locale || 'DE' in locale || 'FR' in locale)
|
||||
USER_EN_SPEAKER: "'en' in locale"
|
||||
USER_DE_SPEAKER: "'de' in locale"
|
||||
USER_FR_SPEAKER: "'fr' in locale"
|
||||
DEVICE_ANDROID: os == 'Android'
|
||||
DEVICE_IOS: os == 'iOS'
|
||||
ALWAYS: "true"
|
||||
NEVER: "false"
|
||||
I_AM_DEFAULT_BROWSER: "is_default_browser"
|
||||
I_AM_NOT_DEFAULT_BROWSER: "is_default_browser == false"
|
||||
USER_ESTABLISHED_INSTALL: "number_of_app_launches >=4"
|
||||
actions:
|
||||
ENABLE_PRIVATE_BROWSING: ://enable_private_browsing
|
||||
INSTALL_SEARCH_WIDGET: ://install_search_widget
|
||||
MAKE_DEFAULT_BROWSER: ://make_default_browser
|
||||
VIEW_BOOKMARKS: ://urls_bookmarks
|
||||
VIEW_COLLECTIONS: ://home_collections
|
||||
VIEW_HISTORY: ://urls_history
|
||||
VIEW_HOMESCREEN: ://home
|
||||
OPEN_SETTINGS_ACCESSIBILITY: ://settings_accessibility
|
||||
OPEN_SETTINGS_ADDON_MANAGER: ://settings_addon_manager
|
||||
OPEN_SETTINGS_DELETE_BROWSING_DATA: ://settings_delete_browsing_data
|
||||
OPEN_SETTINGS_LOGINS: ://settings_logins
|
||||
OPEN_SETTINGS_NOTIFICATIONS: ://settings_notifications
|
||||
OPEN_SETTINGS_PRIVACY: ://settings_privacy
|
||||
OPEN_SETTINGS_SEARCH_ENGINE: ://settings_search_engine
|
||||
OPEN_SETTINGS_TRACKING_PROTECTION: ://settings_tracking_protection
|
||||
OPEN_SETTINGS_WALLPAPERS: ://settings_wallpapers
|
||||
OPEN_SETTINGS: ://settings
|
||||
TURN_ON_SYNC: ://turn_on_sync
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 5
|
||||
SURVEY:
|
||||
priority: 55
|
||||
max-display-count: 10
|
||||
PERSISTENT:
|
||||
priority: 50
|
||||
max-display-count: 20
|
||||
WARNING:
|
||||
priority: 60
|
||||
max-display-count: 10
|
||||
URGENT:
|
||||
priority: 100
|
||||
max-display-count: 10
|
||||
messages:
|
||||
default-browser:
|
||||
text: default_browser_experiment_card_text
|
||||
surface: homescreen
|
||||
action: "MAKE_DEFAULT_BROWSER"
|
||||
trigger: [ "I_AM_NOT_DEFAULT_BROWSER","USER_ESTABLISHED_INSTALL" ]
|
||||
style: "PERSISTENT"
|
||||
button-label: preferences_set_as_default_browser
|
||||
|
||||
- channel: developer
|
||||
value:
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 100
|
||||
EXPIRES_QUICKLY:
|
||||
priority: 100
|
||||
max-display-count: 1
|
||||
notification-config:
|
||||
polling-interval: 180 # 3 minutes
|
||||
|
||||
objects:
|
||||
MessageData:
|
||||
description: >
|
||||
An object to describe a message. It uses human
|
||||
readable strings to describe the triggers, action and
|
||||
style of the message as well as the text of the message
|
||||
and call to action.
|
||||
fields:
|
||||
action:
|
||||
type: Text
|
||||
description: >
|
||||
A URL of a page or a deeplink.
|
||||
This may have substitution variables in.
|
||||
# This should never be defaulted.
|
||||
default: empty_string
|
||||
title:
|
||||
type: Option<Text>
|
||||
description: "The title text displayed to the user"
|
||||
default: null
|
||||
text:
|
||||
type: Text
|
||||
description: "The message text displayed to the user"
|
||||
# This should never be defaulted.
|
||||
default: empty_string
|
||||
is-control:
|
||||
type: Boolean
|
||||
description: "Indicates if this message is the control message, if true shouldn't be displayed"
|
||||
default: false
|
||||
button-label:
|
||||
type: Option<Text>
|
||||
description: >
|
||||
The text on the button. If no text
|
||||
is present, the whole message is clickable.
|
||||
default: null
|
||||
style:
|
||||
type: String
|
||||
description: >
|
||||
The style as described in a
|
||||
`StyleData` from the styles table.
|
||||
default: DEFAULT
|
||||
surface:
|
||||
description:
|
||||
The surface identifier for this message.
|
||||
type: MessageSurfaceId
|
||||
default: homescreen
|
||||
trigger:
|
||||
type: List<String>
|
||||
description: >
|
||||
A list of strings corresponding to
|
||||
targeting expressions. The message will be
|
||||
shown if all expressions `true`.
|
||||
default: []
|
||||
StyleData:
|
||||
description: >
|
||||
A group of properities (predominantly visual) to
|
||||
describe the style of the message.
|
||||
fields:
|
||||
priority:
|
||||
type: Int
|
||||
description: >
|
||||
The importance of this message.
|
||||
0 is not very important, 100 is very important.
|
||||
default: 50
|
||||
max-display-count:
|
||||
type: Int
|
||||
description: >
|
||||
How many sessions will this message be shown to the user
|
||||
before it is expired.
|
||||
default: 5
|
||||
NotificationConfig:
|
||||
description: Attributes controlling the global configuration of notification messages.
|
||||
fields:
|
||||
polling-interval:
|
||||
type: Int
|
||||
description: >
|
||||
How often, in seconds, the notification message worker will wake up and check for new
|
||||
messages.
|
||||
default: 3600
|
||||
|
||||
enums:
|
||||
ControlMessageBehavior:
|
||||
description: An enum to influence what should be displayed when a control message is selected.
|
||||
variants:
|
||||
show-next-message:
|
||||
description: The next eligible message should be shown.
|
||||
show-none:
|
||||
description: The surface should show no message.
|
||||
MessageSurfaceId:
|
||||
description: The identity of a message surface
|
||||
variants:
|
||||
homescreen:
|
||||
description: A banner in the homescreen.
|
||||
notification:
|
||||
description: A local notification in the background, like a push notification.
|
208
nimbus.fml.yaml
208
nimbus.fml.yaml
@ -9,6 +9,8 @@ channels:
|
||||
- beta
|
||||
- nightly
|
||||
- developer
|
||||
includes:
|
||||
- ./messaging.fml.yaml
|
||||
features:
|
||||
homescreen:
|
||||
description: The homescreen that the user goes to when they press home or new tab.
|
||||
@ -73,121 +75,6 @@ features:
|
||||
- channel: developer
|
||||
value:
|
||||
enabled: true
|
||||
messaging:
|
||||
description: |
|
||||
Configuration for the messaging system.
|
||||
|
||||
In practice this is a set of growable lookup tables for the
|
||||
message controller to piece together.
|
||||
|
||||
variables:
|
||||
message-under-experiment:
|
||||
description: Id or prefix of the message under experiment.
|
||||
type: Option<String>
|
||||
default: null
|
||||
|
||||
messages:
|
||||
description: A growable collection of messages
|
||||
type: Map<String, MessageData>
|
||||
default: {}
|
||||
|
||||
triggers:
|
||||
description: >
|
||||
A collection of out the box trigger
|
||||
expressions. Each entry maps to a
|
||||
valid JEXL expression.
|
||||
type: Map<String, String>
|
||||
default: {}
|
||||
styles:
|
||||
description: >
|
||||
A map of styles to configure message
|
||||
appearance.
|
||||
type: Map<String, StyleData>
|
||||
default: {}
|
||||
|
||||
actions:
|
||||
type: Map<String, String>
|
||||
description: A growable map of action URLs.
|
||||
default: {}
|
||||
on-control:
|
||||
type: ControlMessageBehavior
|
||||
description: What should be displayed when a control message is selected.
|
||||
default: show-next-message
|
||||
notification-config:
|
||||
description: Configuration of the notification worker for all notification messages.
|
||||
type: NotificationConfig
|
||||
default: {}
|
||||
defaults:
|
||||
- value:
|
||||
triggers:
|
||||
USER_RECENTLY_INSTALLED: days_since_install < 7
|
||||
USER_RECENTLY_UPDATED: days_since_update < 7 && days_since_install != days_since_update
|
||||
USER_TIER_ONE_COUNTRY: ('US' in locale || 'GB' in locale || 'CA' in locale || 'DE' in locale || 'FR' in locale)
|
||||
USER_EN_SPEAKER: "'en' in locale"
|
||||
USER_DE_SPEAKER: "'de' in locale"
|
||||
USER_FR_SPEAKER: "'fr' in locale"
|
||||
DEVICE_ANDROID: os == 'Android'
|
||||
DEVICE_IOS: os == 'iOS'
|
||||
ALWAYS: "true"
|
||||
NEVER: "false"
|
||||
I_AM_DEFAULT_BROWSER: "is_default_browser"
|
||||
I_AM_NOT_DEFAULT_BROWSER: "is_default_browser == false"
|
||||
USER_ESTABLISHED_INSTALL: "number_of_app_launches >=4"
|
||||
actions:
|
||||
ENABLE_PRIVATE_BROWSING: ://enable_private_browsing
|
||||
INSTALL_SEARCH_WIDGET: ://install_search_widget
|
||||
MAKE_DEFAULT_BROWSER: ://make_default_browser
|
||||
VIEW_BOOKMARKS: ://urls_bookmarks
|
||||
VIEW_COLLECTIONS: ://home_collections
|
||||
VIEW_HISTORY: ://urls_history
|
||||
VIEW_HOMESCREEN: ://home
|
||||
OPEN_SETTINGS_ACCESSIBILITY: ://settings_accessibility
|
||||
OPEN_SETTINGS_ADDON_MANAGER: ://settings_addon_manager
|
||||
OPEN_SETTINGS_DELETE_BROWSING_DATA: ://settings_delete_browsing_data
|
||||
OPEN_SETTINGS_LOGINS: ://settings_logins
|
||||
OPEN_SETTINGS_NOTIFICATIONS: ://settings_notifications
|
||||
OPEN_SETTINGS_PRIVACY: ://settings_privacy
|
||||
OPEN_SETTINGS_SEARCH_ENGINE: ://settings_search_engine
|
||||
OPEN_SETTINGS_TRACKING_PROTECTION: ://settings_tracking_protection
|
||||
OPEN_SETTINGS_WALLPAPERS: ://settings_wallpapers
|
||||
OPEN_SETTINGS: ://settings
|
||||
TURN_ON_SYNC: ://turn_on_sync
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 5
|
||||
SURVEY:
|
||||
priority: 55
|
||||
max-display-count: 10
|
||||
PERSISTENT:
|
||||
priority: 50
|
||||
max-display-count: 20
|
||||
WARNING:
|
||||
priority: 60
|
||||
max-display-count: 10
|
||||
URGENT:
|
||||
priority: 100
|
||||
max-display-count: 10
|
||||
messages:
|
||||
default-browser:
|
||||
text: default_browser_experiment_card_text
|
||||
action: "MAKE_DEFAULT_BROWSER"
|
||||
trigger: [ "I_AM_NOT_DEFAULT_BROWSER","USER_ESTABLISHED_INSTALL" ]
|
||||
style: "PERSISTENT"
|
||||
button-label: preferences_set_as_default_browser
|
||||
|
||||
- channel: developer
|
||||
value:
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 100
|
||||
EXPIRES_QUICKLY:
|
||||
priority: 100
|
||||
max-display-count: 1
|
||||
notification-config:
|
||||
polling-interval: 180 # 3 minutes
|
||||
|
||||
mr2022:
|
||||
description: Features for MR 2022.
|
||||
variables:
|
||||
@ -287,88 +174,9 @@ features:
|
||||
default: true
|
||||
|
||||
types:
|
||||
objects:
|
||||
MessageData:
|
||||
description: >
|
||||
An object to describe a message. It uses human
|
||||
readable strings to describe the triggers, action and
|
||||
style of the message as well as the text of the message
|
||||
and call to action.
|
||||
fields:
|
||||
action:
|
||||
type: Text
|
||||
description: >
|
||||
A URL of a page or a deeplink.
|
||||
This may have substitution variables in.
|
||||
# This should never be defaulted.
|
||||
default: empty_string
|
||||
title:
|
||||
type: Option<Text>
|
||||
description: "The title text displayed to the user"
|
||||
default: null
|
||||
text:
|
||||
type: Text
|
||||
description: "The message text displayed to the user"
|
||||
# This should never be defaulted.
|
||||
default: empty_string
|
||||
is-control:
|
||||
type: Boolean
|
||||
description: "Indicates if this message is the control message, if true shouldn't be displayed"
|
||||
default: false
|
||||
button-label:
|
||||
type: Option<Text>
|
||||
description: >
|
||||
The text on the button. If no text
|
||||
is present, the whole message is clickable.
|
||||
default: null
|
||||
style:
|
||||
type: String
|
||||
description: >
|
||||
The style as described in a
|
||||
`StyleData` from the styles table.
|
||||
default: DEFAULT
|
||||
trigger:
|
||||
type: List<String>
|
||||
description: >
|
||||
A list of strings corresponding to
|
||||
targeting expressions. The message will be
|
||||
shown if all expressions `true`.
|
||||
default: []
|
||||
StyleData:
|
||||
description: >
|
||||
A group of properities (predominantly visual) to
|
||||
describe the style of the message.
|
||||
fields:
|
||||
priority:
|
||||
type: Int
|
||||
description: >
|
||||
The importance of this message.
|
||||
0 is not very important, 100 is very important.
|
||||
default: 50
|
||||
max-display-count:
|
||||
type: Int
|
||||
description: >
|
||||
How many sessions will this message be shown to the user
|
||||
before it is expired.
|
||||
default: 5
|
||||
NotificationConfig:
|
||||
description: Attributes controlling the global configuration of notification messages.
|
||||
fields:
|
||||
polling-interval:
|
||||
type: Int
|
||||
description: >
|
||||
How often, in seconds, the notification message worker will wake up and check for new
|
||||
messages.
|
||||
default: 3600
|
||||
objects: {}
|
||||
|
||||
enums:
|
||||
ControlMessageBehavior:
|
||||
description: An enum to influence what should be displayed when a control message is selected.
|
||||
variants:
|
||||
show-next-message:
|
||||
description: The next eligible message should be shown.
|
||||
show-none:
|
||||
description: The surface should show no message.
|
||||
HomeScreenSection:
|
||||
description: The identifiers for the sections of the homescreen.
|
||||
variants:
|
||||
@ -384,15 +192,7 @@ types:
|
||||
description: The pocket section. This should only be available in the US.
|
||||
pocket-sponsored-stories:
|
||||
description: Subsection of the Pocket homescreen section which shows sponsored stories.
|
||||
MessageSurfaceId:
|
||||
description: The identity of a message surface, used in the default browser experiments
|
||||
variants:
|
||||
app-menu-item:
|
||||
description: An item in the default toolbar menu.
|
||||
settings:
|
||||
description: A setting in the settings screen.
|
||||
homescreen-banner:
|
||||
description: A banner in the homescreen.
|
||||
|
||||
MR2022Section:
|
||||
description: The identifiers for the sections of the MR 2022.
|
||||
variants:
|
||||
|
Loading…
Reference in New Issue
Block a user