@@ -8,12 +8,20 @@ const DEFAULT_NEW_REPORT_ENDPOINT = "https://webcompat.com/issues/new";
88
99import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs" ;
1010
11+ const lazy = { } ;
12+
13+ ChromeUtils . defineESModuleGetters ( lazy , {
14+ ClientEnvironment : "resource://normandy/lib/ClientEnvironment.sys.mjs" ,
15+ } ) ;
16+
1117const gDescriptionCheckRE = / \S / ;
1218
1319class ViewState {
1420 #doc;
1521 #mainView;
1622 #reportSentView;
23+ #reasonOptions;
24+ #randomizeReasons = false ;
1725
1826 currentTabURI ;
1927 currentTabWebcompatDetailsPromise ;
@@ -29,6 +37,11 @@ class ViewState {
2937 "report-broken-site-popup-reportSentView"
3038 ) ;
3139 ViewState . #cache. set ( doc , this ) ;
40+
41+ this . #reasonOptions = Array . from (
42+ // Skip the first option ("choose reason"), since it always stays at the top
43+ this . reasonInput . querySelectorAll ( `menuitem:not(:first-of-type)` )
44+ ) ;
3245 }
3346
3447 static #cache = new WeakMap ( ) ;
@@ -102,6 +115,46 @@ class ViewState {
102115 ) ;
103116 }
104117
118+ #randomizeReasonsOrdering( ) {
119+ // As with QuickActionsLoaderDefault, we use the Normandy
120+ // randomizationId as our PRNG seed to ensure that the same
121+ // user should always get the same sequence.
122+ const seed = [ ...lazy . ClientEnvironment . randomizationId ]
123+ . map ( x => x . charCodeAt ( 0 ) )
124+ . reduce ( ( sum , a ) => sum + a , 0 ) ;
125+
126+ const items = [ ...this . #reasonOptions] ;
127+ this . #shuffleArray( items , seed ) ;
128+ items [ 0 ] . parentNode . append ( ...items ) ;
129+ }
130+
131+ #shuffleArray( array , seed ) {
132+ // We use SplitMix as it is reputed to have a strong distribution of values.
133+ const prng = this . #getSplitMix32PRNG( seed ) ;
134+ for ( let i = array . length - 1 ; i > 0 ; i -- ) {
135+ const j = Math . floor ( prng ( ) * ( i + 1 ) ) ;
136+ [ array [ i ] , array [ j ] ] = [ array [ j ] , array [ i ] ] ;
137+ }
138+ }
139+
140+ // SplitMix32 is a splittable pseudorandom number generator (PRNG).
141+ // License: MIT (https://github.com/attilabuti/SimplexNoise)
142+ #getSplitMix32PRNG( a ) {
143+ return ( ) => {
144+ a |= 0 ;
145+ a = ( a + 0x9e3779b9 ) | 0 ;
146+ var t = a ^ ( a >>> 16 ) ;
147+ t = Math . imul ( t , 0x21f0aaad ) ;
148+ t = t ^ ( t >>> 15 ) ;
149+ t = Math . imul ( t , 0x735a2d97 ) ;
150+ return ( ( t = t ^ ( t >>> 15 ) ) >>> 0 ) / 4294967296 ;
151+ } ;
152+ }
153+
154+ #restoreReasonsOrdering( ) {
155+ this . #reasonOptions[ 0 ] . parentNode . append ( ...this . #reasonOptions) ;
156+ }
157+
105158 static CHOOSE_A_REASON_OPT_ID = "report-broken-site-popup-reason-choose" ;
106159
107160 get chooseAReasonOption ( ) {
@@ -119,6 +172,19 @@ class ViewState {
119172 this . showOrHideReasonValidationMessage ( false ) ;
120173 }
121174
175+ ensureReasonOrderingMatchesPref ( ) {
176+ const randomizeReasons =
177+ this . #doc. ownerGlobal . ReportBrokenSite . randomizeReasons ;
178+ if ( randomizeReasons != this . #randomizeReasons) {
179+ if ( randomizeReasons ) {
180+ this . #randomizeReasonsOrdering( ) ;
181+ } else {
182+ this . #restoreReasonsOrdering( ) ;
183+ }
184+ this . #randomizeReasons = randomizeReasons ;
185+ }
186+ }
187+
122188 get isURLValid ( ) {
123189 return this . urlInput . checkValidity ( ) ;
124190 }
@@ -245,6 +311,8 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
245311 1 : "optional" ,
246312 2 : "required" ,
247313 } ;
314+ static REASON_RANDOMIZED_PREF =
315+ "ui.new-webcompat-reporter.reason-dropdown.randomized" ;
248316 static SEND_MORE_INFO_PREF = "ui.new-webcompat-reporter.send-more-info-link" ;
249317 static NEW_REPORT_ENDPOINT_PREF =
250318 "ui.new-webcompat-reporter.new-report-endpoint" ;
@@ -260,6 +328,7 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
260328
261329 #reasonEnabled = false ;
262330 #reasonIsOptional = true ;
331+ #randomizeReasons = false ;
263332 #descriptionIsOptional = true ;
264333 #sendMoreInfoEnabled = true ;
265334
@@ -271,6 +340,10 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
271340 return this . #reasonIsOptional;
272341 }
273342
343+ get randomizeReasons ( ) {
344+ return this . #randomizeReasons;
345+ }
346+
274347 get descriptionIsOptional ( ) {
275348 return this . #descriptionIsOptional;
276349 }
@@ -279,6 +352,7 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
279352 for ( const [ name , [ pref , dflt ] ] of Object . entries ( {
280353 dataReportingPref : [ ReportBrokenSite . DATAREPORTING_PREF , false ] ,
281354 reasonPref : [ ReportBrokenSite . REASON_PREF , 0 ] ,
355+ reasonRandomizedPref : [ ReportBrokenSite . REASON_RANDOMIZED_PREF , false ] ,
282356 sendMoreInfoPref : [ ReportBrokenSite . SEND_MORE_INFO_PREF , false ] ,
283357 newReportEndpointPref : [
284358 ReportBrokenSite . NEW_REPORT_ENDPOINT_PREF ,
@@ -440,32 +514,8 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
440514
441515 this . #sendMoreInfoEnabled = this . sendMoreInfoPref ;
442516 this . #newReportEndpoint = this . newReportEndpointPref ;
443- }
444517
445- #random( seed ) {
446- let x = Math . sin ( seed ) * 10000 ;
447- return x - Math . floor ( x ) ;
448- }
449-
450- #shuffleArray( array ) {
451- const seed = Math . round ( new Date ( ) . getTime ( ) ) ;
452- for ( let i = array . length - 1 ; i > 0 ; i -- ) {
453- const j = Math . floor ( this . #random( seed ) * ( i + 1 ) ) ;
454- [ array [ i ] , array [ j ] ] = [ array [ j ] , array [ i ] ] ;
455- }
456- }
457-
458- #randomizeDropdownItems( dropdown ) {
459- if ( ! dropdown ) {
460- return ;
461- }
462-
463- // Leave the first option ("choose reason") at the start
464- const items = Array . from (
465- dropdown . querySelectorAll ( `menuitem:not(:first-of-type)` )
466- ) ;
467- this . #shuffleArray( items ) ;
468- items [ 0 ] . parentNode . append ( ...items ) ;
518+ this . #randomizeReasons = this . reasonRandomizedPref ;
469519 }
470520
471521 #initMainView( state ) {
@@ -499,8 +549,6 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
499549 state . showOrHideReasonValidationMessage ( ) ;
500550 } ) ;
501551
502- this . #randomizeDropdownItems( reasonDropdown ) ;
503-
504552 const menupopup = reasonDropdown . querySelector ( "menupopup" ) ;
505553 const onDropDownShowOrHide = ( { type } ) => {
506554 // Hide "choose a reason" while the user has the reason dropdown open
@@ -542,6 +590,8 @@ export var ReportBrokenSite = new (class ReportBrokenSite {
542590
543591 state . reasonInput . hidden = ! this . #reasonEnabled;
544592
593+ state . ensureReasonOrderingMatchesPref ( ) ;
594+
545595 state . reasonLabelRequired . hidden =
546596 ! this . #reasonEnabled || this . #reasonIsOptional;
547597 state . reasonLabelOptional . hidden =
0 commit comments