Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.twitter.follow_recommendations.common.candidate_sources.sims_expansion

import com.google.inject.Singleton
import com.twitter.follow_recommendations.common.candidate_sources.sims.SwitchingSimsSource
import com.twitter.follow_recommendations.common.models.CandidateUser
import com.twitter.product_mixer.core.model.common.identifier.CandidateSourceIdentifier
import com.twitter.stitch.Stitch
import com.twitter.timelines.configapi.HasParams
import com.twitter.finagle.stats.StatsReceiver
import com.twitter.product_mixer.core.model.marshalling.request.HasClientContext
import javax.inject.Inject

/**
* Trait for requests that have a recently muted user ID.
* This is used to find similar accounts to the user that was just muted.
*/
trait HasRecentlyMutedUserId {
def recentlyMutedUserId: Option[Long]
}

object SimilarAccountsToMuteSource {
val Identifier = CandidateSourceIdentifier("SimilarToMutedUser")

// Default configuration values
val DefaultMaxSecondaryDegreeNodes: Int = 20
val DefaultMaxResults: Int = 10
}

/**
* Candidate source for finding similar accounts to mute.
*
* When a user mutes an account, this source finds similar accounts that the user
* might also want to mute. This helps users quickly curate their timeline by
* muting clusters of related accounts at once.
*
* The similarity is based on the same algorithms used for follow recommendations,
* but applied in reverse - instead of finding accounts to follow, we find accounts
* similar to one the user wants to avoid.
*/
@Singleton
class SimilarAccountsToMuteSource @Inject() (
switchingSimsSource: SwitchingSimsSource,
statsReceiver: StatsReceiver)
extends SimsExpansionBasedCandidateSource[
HasParams with HasRecentlyMutedUserId with HasClientContext
](switchingSimsSource) {

override val identifier: CandidateSourceIdentifier = SimilarAccountsToMuteSource.Identifier

private val stats = statsReceiver.scope(identifier.name)
private val muteCandidatesCounter = stats.counter("mute_candidates_generated")
private val noMutedUserCounter = stats.counter("no_muted_user_id")

/**
* The first degree node is the user that was just muted.
* We expand from this user to find similar accounts.
*/
override def firstDegreeNodes(
request: HasParams with HasRecentlyMutedUserId with HasClientContext
): Stitch[Seq[CandidateUser]] = {
request.recentlyMutedUserId match {
case Some(mutedUserId) =>
Stitch.value(Seq(CandidateUser(mutedUserId, score = Some(1.0))))
case None =>
noMutedUserCounter.incr()
Stitch.value(Seq.empty)
}
}

override def maxSecondaryDegreeNodes(
req: HasParams with HasRecentlyMutedUserId with HasClientContext
): Int = {
SimilarAccountsToMuteSource.DefaultMaxSecondaryDegreeNodes
}

override def maxResults(
req: HasParams with HasRecentlyMutedUserId with HasClientContext
): Int = {
muteCandidatesCounter.incr()
SimilarAccountsToMuteSource.DefaultMaxResults
}

/**
* Score candidates based on their similarity to the muted user.
* Higher scores indicate more similar accounts.
*/
override def scoreCandidate(sourceScore: Double, similarToScore: Double): Double = {
sourceScore * similarToScore
}

override def calibrateDivisor(
req: HasParams with HasRecentlyMutedUserId with HasClientContext
): Double = {
1.0 // No calibration needed for mute candidates
}

override def calibrateScore(
candidateScore: Double,
req: HasParams with HasRecentlyMutedUserId with HasClientContext
): Double = {
candidateScore
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.twitter.home_mixer.functional_component.decorator.urt.builder

import com.twitter.home_mixer.product.following.model.HomeMixerExternalStrings
import com.twitter.product_mixer.core.model.marshalling.response.urt.icon
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.BottomSheet
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.ChildFeedbackAction
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.ClientEventInfo
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichBehavior
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichFeedbackBehaviorToggleMuteAllUsers
import com.twitter.product_mixer.core.product.guice.scope.ProductScoped
import com.twitter.stringcenter.client.StringCenter
import javax.inject.Inject
import javax.inject.Singleton

/**
* Builder for the "Mute All" feedback action that appears in the Suggested Mutes dialog.
* This allows users to batch mute all similar accounts after muting a single account,
* helping to balance their algorithm recommendations.
*
* Similar to the "Follow All" button in Suggested Follows, but for muting.
*/
@Singleton
case class MuteAllUsersChildFeedbackActionBuilder @Inject() (
@ProductScoped stringCenter: StringCenter,
externalStrings: HomeMixerExternalStrings) {

/**
* Builds a ChildFeedbackAction for muting all similar users.
*
* @param similarUserIds List of user IDs similar to the user that was just muted
* @param primaryUserScreenName Screen name of the user that was just muted (for display)
* @return Option[ChildFeedbackAction] - None if no similar users to mute
*/
def apply(
similarUserIds: Seq[Long],
primaryUserScreenName: String
): Option[ChildFeedbackAction] = {
if (similarUserIds.isEmpty) {
None
} else {
val prompt = stringCenter.prepare(
externalStrings.muteAllSimilarUsersString,
Map("username" -> primaryUserScreenName, "count" -> similarUserIds.size.toString)
)
val confirmation = stringCenter.prepare(
externalStrings.muteAllSimilarUsersConfirmationString,
Map("count" -> similarUserIds.size.toString)
)
Some(ChildFeedbackAction(
feedbackType = RichBehavior,
prompt = Some(prompt),
confirmation = Some(confirmation),
subprompt = None,
feedbackUrl = None,
hasUndoAction = Some(true),
confirmationDisplayType = Some(BottomSheet),
clientEventInfo = Some(
ClientEventInfo(
component = None,
element = Some("mute_all"),
details = None,
action = Some("click"),
entityToken = None
)
),
icon = Some(icon.SpeakerOff),
richBehavior = Some(RichFeedbackBehaviorToggleMuteAllUsers(similarUserIds)),
childFeedbackActions = None,
encodedFeedbackRequest = None
))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@ object HomeGlobalParams {
default = false
)

/**
* Feature flag to enable the "Mute All" similar accounts feature.
* When enabled, after muting an account, users will see a dialog with
* suggested similar accounts they can batch-mute to balance their algorithm.
*/
object EnableMuteAllSimilarAccountsParam
extends FSParam[Boolean](
name = "home_mixer_enable_mute_all_similar_accounts",
default = false
)

object ListMandarinTweetsParams {
object ListMandarinTweetsEnable
extends FSParam[Boolean](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class HomeMixerExternalStrings @Inject() (
externalStringRegistryProvider.get().createProdString("Feedback.muteUser")
val muteUserConfirmationString =
externalStringRegistryProvider.get().createProdString("Feedback.muteUserConfirmation")
val muteAllSimilarUsersString =
externalStringRegistryProvider.get().createProdString("Feedback.muteAllSimilarUsers")
val muteAllSimilarUsersConfirmationString =
externalStringRegistryProvider.get().createProdString("Feedback.muteAllSimilarUsersConfirmation")
val blockUserString =
externalStringRegistryProvider.get().createProdString("Feedback.blockUser")
val blockUserConfirmationString =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.twitter.product_mixer.core.model.marshalling.response.urt.cover.Cover
import com.twitter.product_mixer.core.model.marshalling.response.urt.cover.HalfCoverDisplayType
import com.twitter.product_mixer.core.model.marshalling.response.urt.icon._
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.FollowAllMessageActionType
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.MuteAllMessageActionType
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.LargeUserFacepileDisplayType
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.MessageAction
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.MessageActionType
Expand Down Expand Up @@ -244,6 +245,7 @@ object OnboardingInjectionConversions {
): MessageActionType =
actionType match {
case onboardingthrift.FacepileActionType.FollowAll => FollowAllMessageActionType
case onboardingthrift.FacepileActionType.MuteAll => MuteAllMessageActionType
case onboardingthrift.FacepileActionType.EnumUnknownFacepileActionType(value) =>
throw new UnsupportedOperationException(s"Unknown product: $value")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.twitter.product_mixer.component_library.module

import com.twitter.conversions.DurationOps._
import com.twitter.conversions.PercentOps._
import com.twitter.finagle.thriftmux.MethodBuilder
import com.twitter.finatra.mtls.thriftmux.modules.MtlsClient
import com.twitter.inject.Injector
import com.twitter.inject.thrift.modules.ThriftMethodBuilderClientModule
import com.twitter.follow_recommendations.thriftscala.FollowRecommendationsThriftService
import com.twitter.util.Duration

/**
* Module for the Mute Recommender Service.
*
* This service provides recommendations for similar accounts to mute
* after a user mutes an account, similar to how FollowRecommenderServiceModule
* provides follow recommendations.
*
* The service reuses the FollowRecommendationsThriftService infrastructure
* but will be configured to return mute candidates instead of follow candidates.
*/
object MuteRecommenderServiceModule
extends ThriftMethodBuilderClientModule[
FollowRecommendationsThriftService.ServicePerEndpoint,
FollowRecommendationsThriftService.MethodPerEndpoint
]
with MtlsClient {

override val label: String = "mute-recommendations-service"

// Uses the follow-recommendations service endpoint with mute-specific configuration
// In production, this would point to a dedicated mute-recommendations service
override val dest: String = "/s/follow-recommendations/follow-recos-service"

override protected def configureMethodBuilder(
injector: Injector,
methodBuilder: MethodBuilder
): MethodBuilder = {
methodBuilder
.withTimeoutPerRequest(400.millis)
.withTimeoutTotal(800.millis)
.idempotent(5.percent)
}

override protected def sessionAcquisitionTimeout: Duration = 500.milliseconds
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.twitter.product_mixer.core.functional_component.marshaller.response.urt.item.message

import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.FollowAllMessageActionType
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.MuteAllMessageActionType
import com.twitter.product_mixer.core.model.marshalling.response.urt.item.message.MessageActionType
import com.twitter.timelines.render.{thriftscala => urt}
import javax.inject.Inject
Expand All @@ -11,5 +12,6 @@ class MessageActionTypeMarshaller @Inject() () {

def apply(messageActionType: MessageActionType): urt.MessageActionType = messageActionType match {
case FollowAllMessageActionType => urt.MessageActionType.FollowAll
case MuteAllMessageActionType => urt.MessageActionType.MuteAll
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.Ri
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichFeedbackBehaviorToggleFollowUser
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichFeedbackBehaviorToggleMuteList
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichFeedbackBehaviorToggleMuteUser
import com.twitter.product_mixer.core.model.marshalling.response.urt.metadata.RichFeedbackBehaviorToggleMuteAllUsers
import com.twitter.timelines.render.{thriftscala => urt}
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -47,6 +48,8 @@ class RichFeedbackBehaviorMarshaller @Inject() () {
urt.RichFeedbackBehavior.ReplyPinState(urt.RichFeedbackBehaviorReplyPinState(pinState))
case RichFeedbackBehaviorToggleMuteUser(userId) =>
urt.RichFeedbackBehavior.ToggleMuteUser(urt.RichFeedbackBehaviorToggleMuteUser(userId))
case RichFeedbackBehaviorToggleMuteAllUsers(userIds) =>
urt.RichFeedbackBehavior.ToggleMuteAllUsers(urt.RichFeedbackBehaviorToggleMuteAllUsers(userIds))
case RichFeedbackBehaviorToggleFollowUser(userId) =>
urt.RichFeedbackBehavior.ToggleFollowUser(urt.RichFeedbackBehaviorToggleFollowUser(userId))
case RichFeedbackBehaviorReportTweet(entryId) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ package com.twitter.product_mixer.core.model.marshalling.response.urt.item.messa
sealed trait MessageActionType

case object FollowAllMessageActionType extends MessageActionType
case object MuteAllMessageActionType extends MessageActionType
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ case class RichFeedbackBehaviorMarkNotInterestedTopic(topicId: String) extends R
case class RichFeedbackBehaviorReplyPinState(replyPinState: ReplyPinState)
extends RichFeedbackBehavior
case class RichFeedbackBehaviorToggleMuteUser(userId: Long) extends RichFeedbackBehavior
case class RichFeedbackBehaviorToggleMuteAllUsers(userIds: Seq[Long]) extends RichFeedbackBehavior
case class RichFeedbackBehaviorToggleFollowUser(userId: Long) extends RichFeedbackBehavior
case class RichFeedbackBehaviorReportTweet(entryId: Long) extends RichFeedbackBehavior
4 changes: 4 additions & 0 deletions unified_user_actions/service/src/main/resources/decider.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ PublishServerProfileMute:
default_availability: 0
PublishServerProfileUnmute:
default_availability: 0
PublishServerProfileMuteAll:
default_availability: 10000
PublishServerProfileReport:
default_availability: 0
PublishClientTweetFav:
Expand Down Expand Up @@ -163,6 +165,8 @@ PublishClientProfileFollow:
default_availability: 0
PublishClientProfileClick:
default_availability: 0
PublishClientProfileMuteAll:
default_availability: 10000
PublishClientTweetFollowAuthor:
default_availability: 0
PublishClientTweetUnfollowAuthor:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ enum ActionType {
// This user action type includes ProfileReportAsSpam and ProfileReportAsAbuse
ServerProfileReport = 56
// reserve 57 for ServerProfileUnReport
// reserve 56-70 for server social graph actions
ServerProfileMuteAll = 58 // User mutes all similar profiles (batch mute from Suggested Mutes)
// reserve 59-70 for server social graph actions

// 71-90 reserved for click-based events
// reserve 71 for ServerTweetClick
Expand Down Expand Up @@ -295,7 +296,10 @@ enum ActionType {
// (eg: People Search / people module in Top tab in Search Result Page
// For tweets, the click is captured in ClientTweetClickProfile
ClientProfileClick = 1058
// reserve 1059-1070 for client social graph actions
// This is fired when a user clicks "Mute All" in the Suggested Mutes dialog
// to batch mute all similar profiles to the one they just muted
ClientProfileMuteAll = 1059
// reserve 1060-1070 for client social graph actions

// This is fired when a user clicks Follow in the caret menu of a Tweet or hovers on the avatar of the tweet
// author and clicks on the Follow button. A profile can also be followed by clicking the Follow button on the
Expand Down