diff --git a/follow-recommendations-service/common/src/main/scala/com/twitter/follow_recommendations/common/candidate_sources/sims_expansion/SimilarAccountsToMuteSource.scala b/follow-recommendations-service/common/src/main/scala/com/twitter/follow_recommendations/common/candidate_sources/sims_expansion/SimilarAccountsToMuteSource.scala new file mode 100644 index 000000000..f6c4590a1 --- /dev/null +++ b/follow-recommendations-service/common/src/main/scala/com/twitter/follow_recommendations/common/candidate_sources/sims_expansion/SimilarAccountsToMuteSource.scala @@ -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 + } +} diff --git a/home-mixer/server/src/main/scala/com/twitter/home_mixer/functional_component/decorator/urt/builder/MuteAllUsersChildFeedbackActionBuilder.scala b/home-mixer/server/src/main/scala/com/twitter/home_mixer/functional_component/decorator/urt/builder/MuteAllUsersChildFeedbackActionBuilder.scala new file mode 100644 index 000000000..0060fe614 --- /dev/null +++ b/home-mixer/server/src/main/scala/com/twitter/home_mixer/functional_component/decorator/urt/builder/MuteAllUsersChildFeedbackActionBuilder.scala @@ -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 + )) + } + } +} diff --git a/home-mixer/server/src/main/scala/com/twitter/home_mixer/param/HomeGlobalParams.scala b/home-mixer/server/src/main/scala/com/twitter/home_mixer/param/HomeGlobalParams.scala index f48870bb6..712e661e1 100644 --- a/home-mixer/server/src/main/scala/com/twitter/home_mixer/param/HomeGlobalParams.scala +++ b/home-mixer/server/src/main/scala/com/twitter/home_mixer/param/HomeGlobalParams.scala @@ -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]( diff --git a/home-mixer/server/src/main/scala/com/twitter/home_mixer/product/following/model/HomeMixerExternalStrings.scala b/home-mixer/server/src/main/scala/com/twitter/home_mixer/product/following/model/HomeMixerExternalStrings.scala index bbcd8a354..195484d8b 100644 --- a/home-mixer/server/src/main/scala/com/twitter/home_mixer/product/following/model/HomeMixerExternalStrings.scala +++ b/home-mixer/server/src/main/scala/com/twitter/home_mixer/product/following/model/HomeMixerExternalStrings.scala @@ -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 = diff --git a/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/decorator/urt/builder/flexible_injection_pipeline/OnboardingInjectionConversions.scala b/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/decorator/urt/builder/flexible_injection_pipeline/OnboardingInjectionConversions.scala index 92c8d97d7..702d47e06 100644 --- a/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/decorator/urt/builder/flexible_injection_pipeline/OnboardingInjectionConversions.scala +++ b/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/decorator/urt/builder/flexible_injection_pipeline/OnboardingInjectionConversions.scala @@ -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 @@ -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") } diff --git a/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/module/MuteRecommenderServiceModule.scala b/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/module/MuteRecommenderServiceModule.scala new file mode 100644 index 000000000..8ac2d084a --- /dev/null +++ b/product-mixer/component-library/src/main/scala/com/twitter/product_mixer/component_library/module/MuteRecommenderServiceModule.scala @@ -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 +} diff --git a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/item/message/MessageActionTypeMarshaller.scala b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/item/message/MessageActionTypeMarshaller.scala index dde02b150..c1aee7b80 100644 --- a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/item/message/MessageActionTypeMarshaller.scala +++ b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/item/message/MessageActionTypeMarshaller.scala @@ -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 @@ -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 } } diff --git a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/metadata/RichFeedbackBehaviorMarshaller.scala b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/metadata/RichFeedbackBehaviorMarshaller.scala index 43ec36fe4..e50628f61 100644 --- a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/metadata/RichFeedbackBehaviorMarshaller.scala +++ b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/functional_component/marshaller/response/urt/metadata/RichFeedbackBehaviorMarshaller.scala @@ -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 @@ -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) => diff --git a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/item/message/MessageActionType.scala b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/item/message/MessageActionType.scala index feeaab391..9ea72dcfb 100644 --- a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/item/message/MessageActionType.scala +++ b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/item/message/MessageActionType.scala @@ -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 diff --git a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/metadata/RichFeedbackBehavior.scala b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/metadata/RichFeedbackBehavior.scala index 2ae86471f..33dc954ac 100644 --- a/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/metadata/RichFeedbackBehavior.scala +++ b/product-mixer/core/src/main/scala/com/twitter/product_mixer/core/model/marshalling/response/urt/metadata/RichFeedbackBehavior.scala @@ -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 diff --git a/unified_user_actions/service/src/main/resources/decider.yml b/unified_user_actions/service/src/main/resources/decider.yml index 23aa40bc3..14c8d2862 100644 --- a/unified_user_actions/service/src/main/resources/decider.yml +++ b/unified_user_actions/service/src/main/resources/decider.yml @@ -37,6 +37,8 @@ PublishServerProfileMute: default_availability: 0 PublishServerProfileUnmute: default_availability: 0 +PublishServerProfileMuteAll: + default_availability: 10000 PublishServerProfileReport: default_availability: 0 PublishClientTweetFav: @@ -163,6 +165,8 @@ PublishClientProfileFollow: default_availability: 0 PublishClientProfileClick: default_availability: 0 +PublishClientProfileMuteAll: + default_availability: 10000 PublishClientTweetFollowAuthor: default_availability: 0 PublishClientTweetUnfollowAuthor: diff --git a/unified_user_actions/thrift/src/main/thrift/com/twitter/unified_user_actions/action_info.thrift b/unified_user_actions/thrift/src/main/thrift/com/twitter/unified_user_actions/action_info.thrift index 1342b5cf7..1b56c5016 100644 --- a/unified_user_actions/thrift/src/main/thrift/com/twitter/unified_user_actions/action_info.thrift +++ b/unified_user_actions/thrift/src/main/thrift/com/twitter/unified_user_actions/action_info.thrift @@ -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 @@ -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