1/** 2 * ForumSystems/RegisterPrompt: the client gate for the register prompt. 3 * 4 * The server has already decided this guest page view is eligible (the board 5 * option, the data version, the network stop; see Callback::renderMode), 6 * and whether the modal may open on its own here: data-fs-rp-auto="1" on the 7 * gate span, or "0" on a page the exclusion rules name (1.1.3: the modal is in 8 * the page for a deliberate click only, so a notice's "Subscribe now" or the 9 * header Register link opens it in place; nothing below arms a timer there). 10 * This script owns the facts only the browser can know, and holds the modal 11 * back until every one of them passes: 12 * 13 * - not before the floor (default 5s, measured from activation for a 14 * prerendered page, not from prerender start) 15 * - not before the first ad creative has rendered, or the fallback timer, or 16 * immediately if the page carries no ad code at all 17 * - not more than N per tab session, not more than M per rolling window 18 * - never while a "No thanks" is in force on this device 19 * - never on the first page view of a search-engine landing 20 * - never in a prerendered or hidden document 21 * 22 * When they all pass it opens the modal, records an impression, and then 23 * watches for a button or for the visitor to leave. An impression ends exactly 24 * once: a button, a dismissal, or leaving. Every dismissal names how it 25 * happened, and the names are distinct (the dashboard reads them by reason): 26 * 27 * user_no_thanks the "No thanks" button (suppresses for dismissTtlDays) 28 * user_close the X in the corner 29 * user_escape the Escape key or a tap on the backdrop 30 * idle nothing happened for noInteractionMs (a marker only: the 31 * modal stays open and a later action still records) 32 * page_unload the visitor left with the modal still open 33 * 34 * The buttons themselves are the board's own registration: a provider pill is 35 * a POST form to register/connected-accounts/{provider}?setup=1, the email 36 * button a link to /register. This script only records the click and lets the 37 * browser navigate. It creates nothing, logs nobody in, and never fetches an 38 * endpoint that could. 39 * 40 * door5 "Sign up here" (1.1.0) puts the board's own register form inside the 41 * modal instead of linking out to it. loadRegisterForm() fetches the SAME 42 * /register link the email pill carries (with _xfRedirect = this page and 43 * fs_rp=1, so the RegisterController extension sets the door and keeps the 44 * return path), asking for it the way XF's own overlay does 45 * (_xfResponseType=json, _xfWithData=1, _xfRequestUri, _xfToken, the 46 * X-Requested-With header), takes html.content from the answer, keeps only the 47 * register form out of it, appends its own _xfRedirect and fs_rp hidden inputs, 48 * and hands the container to XF.setupHtmlInsert / XF.activate so XF's handlers 49 * run on it as on the register page: the ajax submit, the registration timer, 50 * the password meter, the captcha. The form is XenForo's, with XenForo's hidden 51 * fields (_xfToken, reg_key, the hashed field names, the honeypots, the 52 * timezone), and it POSTs to XenForo's register/register; on success XF's own 53 * ajax handling follows the redirect to register/complete. If the fetch fails, 54 * or what comes back is not the form, the email pill under the container is 55 * shown instead, so the modal always works. The only thing recorded is the 56 * submit: provider_click provider=email path=email, once per impression. 57 * Since 1.2.2 the form is fetched AHEAD of any click, once the page has 58 * settled, and kept parsed but not activated; a click puts it in at once, and a 59 * click that comes first fetches at once instead of waiting for the load event 60 * (Keith on rabbittalk: "a long delay before the fields show up"). The 61 * impression of a click-opened door5 carries latency_ms = click to fields. 62 * Since 1.2.4 the fields come FIRST: the form is parsed the moment the 63 * register HTML arrives and goes into the modal typeable, before the register 64 * page's JS and CSS have loaded; XF's activation (those assets, the inline 65 * scripts, XF.activate) then enhances the very same elements in place, and the 66 * submit is held (disabled buttons, a submit guard) until it has. The fetch 67 * also starts on intent (the first pointerdown, touchstart or focus on a 68 * Register control); a form fetched before the page settled is swapped for a 69 * fresh one once it has, everything typed carried over, and a submit XF 70 * refuses for its key is re-filled from what was typed (Keith: "it needs to 71 * be instantaneous"; one visitor in ten waited over a second, two waited 7 to
72 * 9 s for the assets on a slow phone). And a crawler that runs 73 * this script (isCrawlerUa, navigator.webdriver) is held in cta mode: no 74 * automatic open, no impression for one, so the page the server hands every 75 * guest alike never shows a search engine's renderer an interstitial. 76 * 77 * Storage keys are the SAME names the server reads and the consent registry 78 * declares: fs_rp_vid, fs_rp_dismiss, fs_rp_seen, fs_rp_variant (site-prefixed 79 * on the wire, e.g. xf_fs_rp_vid). 80 * 81 * Every write here is best-effort measurement. Nothing in this file can 82 * prevent a registration; if it throws, the prompt simply does not show. 83 */ 84(function () { 85 'use strict'; 86 87 if (window.FsRegisterPrompt) { return; } 88 89 var hook = document.querySelector('.js-fsRpGate'); 90 var cfgEl = document.querySelector('script.js-fsRpConfig'); 91 var modal = document.querySelector('.js-fsRpModal'); 92 if (!hook || !cfgEl || !modal) { return; } 93 94 // ---------------------------------------------------------------- config 95 var DEFAULTS = { 96 floorMs: 5000, adGateFallbackMs: 8000, noInteractionMs: 20000, 97 sessionCap: 1, rollingCapCount: 2, rollingCapDays: 7, dismissTtlDays: 56, 98 searchHosts: ['google.com', 'bing.com', 'yahoo.com', 'duckduckgo.com', 'baidu.com', 'yandex.com', 'yandex.ru', 'ecosia.org', 'startpage.com', 'search.brave.com'], 99 hijackRegisterLinks: true, 100 eventUrl: '', v: '' 101 }; 102 var cfg = null; 103 try { cfg = Object.assign({}, DEFAULTS, JSON.parse(cfgEl.textContent || '')); } catch (e) { cfg = null; } 104 // No parseable config, or no beacon endpoint, would mean showing a prompt we 105 // cannot record. Fail toward not showing. 106 if (!cfg || !cfg.eventUrl) { return; } 107 if (cfg.killSwitch) { return; } 108 // ?fs_rp_preview=1: open at once, ignore caps and timing, record nothing. 109 // For looking at the modal, never for measuring it. 110 var PREVIEW = /[?&]fs_rp_preview=1(&|$)/.test(location.search); 111 var templateName = hook.getAttribute('data-fs-rp-template') || ''; 112 var variantName = hook.getAttribute('data-fs-rp-variant') || ''; // the arm: "door3" or "door3:2" 113 var baseVariant = variantName.split(':')[0], armHeadline = variantName.split(':')[1] || ''; 114 var MEMBER_PREVIEW = hook.getAttribute('data-fs-rp-member') === '1'; 115 // 1.1.3: data-fs-rp-auto="0" means the server rendered the modal on a page the 116 // exclusion rules name (the forum home, a member page): it is here for a 117 // deliberate click only. Nothing below arms a timer, an ad gate or a cap check 118 // in that case; the click delegate and window.FsRegisterPrompt.open still run. 119 // A missing attribute (a page rendered by an older template) reads as "1". 120 // 121 // 1.2.4: a crawler that runs JavaScript (Google's renderer does) must never see the modal open 122 // on its own: an interstitial in a rendered snapshot is an SEO signal, and its impression would 123 // be a number nobody saw. The server hands a crawler the same HTML as any guest (XenForo's page 124 // cache is robot-blind, so it must); this is the client's half: isCrawlerUa() (the known 125 // crawlers by name, and the generic words on word boundaries, so a Cubot phone or a robotics 126 // forum is not a bot) or navigator.webdriver puts the page in cta mode. No timing gates, no 127 // automatic open, no impression for one, nothing logged; a deliberate click still opens it. 128 function isCrawlerUa(ua) { 129 return /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|applebot|facebookexternalhit|twitterbot|linkedinbot|petalbot|ahrefs|semrush|mj12bot|dotbot|\b(?:bot|crawler|spider|crawling)\b/i.test(String(ua || '')); 130 } 131 var CRAWLER = isCrawlerUa(navigator.userAgent) || navigator.webdriver === true; 132 var AUTO = hook.getAttribute('data-fs-rp-auto') !== '0' && !CRAWLER; 133 134 var K_VID = 'fs_rp_vid', K_DISMISS = 'fs_rp_dismiss', K_SEEN = 'fs_rp_seen', K_SESS = 'fs_rp_sess', K_VARIANT = 'fs_rp_variant'; 135 var EXPERIMENT = hook.getAttribute('data-fs-rp-experiment') === '1'; 136 var ASSIGNED_NOW = hook.getAttribute('data-fs-rp-assigned') === '1'; 137 var MIX = (hook.getAttribute('data-fs-rp-mix') || '').split(','); 138 var AD_HOSTS = ['googlesyndication.com', 'doubleclick.net', 'adthrive.com', 'armanet.us', 'imasdk.googleapis.com', 'adnxs.com', 'media.net']; 139 // Presence = "this page carries ad code at all". fshb-slot- is this network's 140 // own HeaderBidding slot id; the rest are the providers seen on the pilot sites. 141 var AD_PRESENCE = 'script[src*="adthrive"],script[src*="armanet"],script[src*="googlesyndication"],script[src*="googletagservices"],script[src*="doubleclick"],.adthrive-ad,[data-armanet],ins.adsbygoogle,[id^="fshb-slot-"],[id^="div-gpt-ad"]'; 142 143 // ---------------------------------------------------------------- storage 144 // Self-contained on purpose: XF.LocalStorage's cookie fallback is opt-in per 145 // call and easy to miss. Cookie + localStorage are written together and read 146 // OR-toward-suppression; sessionStorage, then memory, are the fallbacks. 147 var cookieCfg = (window.XF && XF.config && XF.config.cookie) || {}; 148 var PREFIX = cookieCfg.prefix || 'xf_'; 149 var mem = {}; 150 151 function cookieGet(name) { 152 var m = document.cookie.match('(?:^|; )' + (PREFIX + name).replace(/[.$?*|{}()\[\]\\\/+^]/g, '\\$&') + '=([^;]*)'); 153 return m ? decodeURIComponent(m[1]) : null; 154 } 155 function cookieSet(name, value, days) { 156 try { 157 document.cookie = PREFIX + name + '=' + encodeURIComponent(value) 158 + '; Max-Age=' + Math.round(days * 86400) 159 + '; Path=' + (cookieCfg.path || '/') 160 + (cookieCfg.domain ? '; Domain=' + cookieCfg.domain : '') 161 + '; SameSite=Lax' + (location.protocol === 'https:' ? '; Secure' : ''); 162 } catch (e) { /* blocked */ } 163 } 164 function lsGet(name) { try { return localStorage.getItem(PREFIX + name); } catch (e) { return null; } } 165 function lsSet(name, value) { try { localStorage.setItem(PREFIX + name, value); return true; } catch (e) { return false; } } 166 function ssGet(name) { try { return sessionStorage.getItem(PREFIX + name); } catch (e) { return mem[name] || null; } } 167 function ssSet(name, value) { try { sessionStorage.setItem(PREFIX + name, value); } catch (e) { mem[name] = value; } } 168 169 function readBoth(name) { return { cookie: cookieGet(name), ls: lsGet(name) }; } 170 function writeBoth(name, value, days) { cookieSet(name, value, days); if (!lsSet(name, value)) { ssSet(name, value); } } 171 172 // ---------------------------------------------------------------- identity 173 function uuid() { 174 if (window.crypto && crypto.randomUUID) { return crypto.randomUUID(); } 175 var b = new Uint8Array(16); 176 if (window.crypto && crypto.getRandomValues) { crypto.getRandomValues(b); } else { for (var i = 0; i < 16; i++) { b[i] = Math.random() * 256 | 0; } } 177 b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; 178 var h = Array.prototype.map.call(b, function (x) { return ('0' + x.toString(16)).slice(-2); }).join(''); 179 return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20); 180 } 181 var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 182 function visitorId() { 183 var r = readBoth(K_VID), v = (r.cookie && UUID_RE.test(r.cookie)) ? r.cookie : ((r.ls && UUID_RE.test(r.ls)) ? r.ls : null); 184 if (!v) { v = uuid(); } 185 writeBoth(K_VID, v, 180); // re-set on every read: keep both channels in sync 186 return v; 187 } 188 189 // ---------------------------------------------------------------- experiment pin 190 // The server pins a visitor's variant in a cookie on their first eligible 191 // view. Mirror it into localStorage like the visitor id, and if the cookie 192 // was lost but the mirror survives, put the mirror back so the next page 193 // shows the variant this visitor has always seen. Only a mirror that is 194 // still in the mix comes back. 195 function syncVariantPin() { 196 if (!EXPERIMENT || PREVIEW || !variantName) { return; } 197 var mirrored = lsGet(K_VARIANT); 198 if (ASSIGNED_NOW && mirrored && mirrored !== variantName && MIX.indexOf(mirrored) !== -1) { 199 cookieSet(K_VARIANT, mirrored, 90); 200 return; 201 } 202 writeBoth(K_VARIANT, variantName, 90); 203 } 204 try { syncVariantPin(); } catch (e) { /* storage blocked */ } 205 206 // ---------------------------------------------------------------- suppression state 207 function dismissedUntil() { 208 var r = readBoth(K_DISMISS); 209 return Math.max(parseInt(r.cookie || '0', 10) || 0, parseInt(r.ls || '0', 10) || 0); 210 } 211 function dismissed() { 212 var until = dismissedUntil(); 213 if (until > Date.now()) { writeBoth(K_DISMISS, String(until), cfg.dismissTtlDays); return true; } // bump transport TTL, not the window 214 return false;
215 } 216 function seenList() { 217 var r = readBoth(K_SEEN), out = [], cut = Date.now() - cfg.rollingCapDays * 86400 * 1000; 218 [r.cookie, r.ls].forEach(function (raw) { 219 try { (JSON.parse(raw || '{"t":[]}').t || []).forEach(function (t) { if (t > cut && out.indexOf(t) < 0) { out.push(t); } }); } catch (e) { /* ignore */ } 220 }); 221 return out.sort(); 222 } 223 function rollingCapHit() { return seenList().length >= cfg.rollingCapCount; } 224 function recordSeen() { 225 var t = seenList(); t.push(Date.now()); 226 writeBoth(K_SEEN, JSON.stringify({ t: t.slice(-cfg.rollingCapCount) }), cfg.rollingCapDays + 1); 227 ssSet(K_SESS, String((parseInt(ssGet(K_SESS) || '0', 10) || 0) + 1)); 228 } 229 function sessionCapHit() { return (parseInt(ssGet(K_SESS) || '0', 10) || 0) >= cfg.sessionCap; } 230 231 function hostMatches(host, list) { 232 return list.some(function (s) { s = String(s).toLowerCase().replace(/^\.+|\.+$/g, ''); return s && (host === s || host.slice(-(s.length + 1)) === '.' + s); }); 233 } 234 function searchLanding() { 235 try { 236 if (!document.referrer) { return false; } 237 var h = new URL(document.referrer).hostname.toLowerCase(); 238 if (h === location.hostname.toLowerCase()) { return false; } 239 return hostMatches(h, cfg.searchHosts || []); 240 } catch (e) { return false; } 241 } 242 243 // ---------------------------------------------------------------- beacon 244 function beacon(type, extra) { 245 if (!cfg.eventUrl || PREVIEW) { return; } 246 try { 247 var fd = new FormData(); 248 fd.append('event_type', type); 249 fd.append('client_event_id', uuid()); 250 fd.append('occurred_at', String(Math.floor(Date.now() / 1000))); 251 fd.append('template_name', templateName); 252 // Where it happened: XF stamps <html data-content-key="thread-123" data-container-key="node-4">. 253 var root = document.documentElement; 254 fd.append('content', root.getAttribute('data-content-key') || ''); 255 fd.append('container', root.getAttribute('data-container-key') || ''); 256 fd.append('path', (extra && extra.path) || 'social'); 257 if (variantName) { fd.append('variant', variantName); } 258 // latency_ms: time since the page could be seen, or what the caller measured (1.2.2: click to fields for a cta open). 259 fd.append('latency_ms', String(extra && extra.latency_ms != null ? extra.latency_ms : Math.max(0, Math.round(sinceActivation())))); 260 Object.keys(extra || {}).forEach(function (k) { if (k !== 'path' && k !== 'latency_ms' && extra[k] != null) { fd.append(k, String(extra[k])); } }); 261 if (window.XF && XF.config && XF.config.csrf) { fd.append('_xfToken', XF.config.csrf); } 262 fd.append('_xfResponseType', 'json'); 263 if (navigator.sendBeacon && navigator.sendBeacon(cfg.eventUrl, fd)) { return; } 264 fetch(cfg.eventUrl, { method: 'POST', body: fd, credentials: 'same-origin', keepalive: true }).catch(function () {}); 265 } catch (e) { /* measurement only */ } 266 } 267 268 // ---------------------------------------------------------------- gates 269 // Time since the visitor could actually see the page. For a prerendered 270 // document performance.now() runs from prerender start; activationStart is 271 // the offset the Prerendering spec provides to correct for that. 272 function sinceActivation() { 273 var nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; 274 var act = (nav && nav.activationStart) || 0; 275 return performance.now() - act; 276 } 277 function floorGate() { 278 return new Promise(function (resolve) { 279 var elapsed = sinceActivation(); 280 if (elapsed >= cfg.floorMs) { resolve(); } else { setTimeout(resolve, cfg.floorMs - elapsed); } 281 }); 282 } 283 284 function isAdIframe(el) { 285 if (!el || el.tagName !== 'IFRAME') { return false; } 286 var src = el.getAttribute('src') || ''; 287 return AD_HOSTS.some(function (h) { return src.indexOf(h) >= 0; }); 288 } 289 function anyAdIframe() { return Array.prototype.some.call(document.getElementsByTagName('iframe'), isAdIframe); } 290 291 function adGate() { 292 return new Promise(function (resolve) { 293 if (!document.querySelector(AD_PRESENCE)) { resolve('no-slot'); return; } // nothing to wait for 294 if (anyAdIframe()) { resolve('ad-rendered'); return; } 295 var done = false, obs, timer; 296 function settle(reason) { if (done) { return; } done = true; if (obs) { obs.disconnect(); } clearTimeout(timer); resolve(reason); }
297 try { 298 obs = new MutationObserver(function () { if (anyAdIframe()) { settle('ad-rendered'); } }); 299 obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); 300 } catch (e) { /* observer unavailable: timer decides */ } 301 timer = setTimeout(function () { settle('fallback-timer'); }, cfg.adGateFallbackMs); 302 }); 303 } 304 305 function visibleGate() { 306 return new Promise(function (resolve) { 307 function ok() { return !document.prerendering && document.visibilityState === 'visible'; } 308 if (ok()) { resolve(); return; } 309 function check() { if (ok()) { document.removeEventListener('visibilitychange', check); document.removeEventListener('prerenderingchange', check); resolve(); } } 310 document.addEventListener('visibilitychange', check); 311 document.addEventListener('prerenderingchange', check); 312 }); 313 } 314 315 // ---------------------------------------------------------------- activation 316 var state = { shown: false, ended: false, timer: null, reason: '' }; 317 318 // 1.2.2: for a click-opened door5 the impression beacon is sent once the fields are visible 319 // (or the fallback pill), with latency_ms = click to fields, so the dashboard sees what the 320 // visitor waited for. A gate-driven open keeps latency_ms = time since the page could be seen. 321 // Leaving before the fields show flushes it with the time waited so far (end()). 322 var pendingImpression = null; 323 function fieldsShown() { 324 if (!pendingImpression) { return; } 325 var p = pendingImpression; 326 pendingImpression = null; 327 beacon('impression', Object.assign({}, p.extra, { latency_ms: Math.max(0, Math.round(performance.now() - p.at)) })); 328 } 329 330 function deactivate() { 331 if (state.timer) { clearTimeout(state.timer); state.timer = null; } 332 closeModal(); 333 } 334 335 // An impression ends once. Whichever of button / dismissal / leaving 336 // happens first wins; the others become no-ops. 337 function end(how, extra) { 338 if (!state.shown || state.ended) { return false; } 339 state.ended = true; 340 if (state.timer) { clearTimeout(state.timer); state.timer = null; } 341 fieldsShown(); // an impression still waiting for its fields goes first, with the time waited 342 beacon(how, extra); 343 return true; 344 } 345 346 function suppressedNow() { 347 if (PREVIEW) { return false; } 348 if (dismissed()) { state.reason = 'dismissed'; return true; } 349 if (rollingCapHit()) { state.reason = 'rolling_cap'; return true; } 350 if (sessionCapHit()) { state.reason = 'session_cap'; return true; } 351 return false; 352 } 353 354 // ---------------------------------------------------------------- focus 355 // The modal calls itself a dialog, so it has to behave like one: the page 356 // behind it goes inert, Tab cycles inside, and whatever the visitor was on 357 // gets its focus back when we close. Everything here degrades quietly. 358 var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; 359 function focusables() { 360 return Array.prototype.filter.call(modal.querySelectorAll(FOCUSABLE), function (el) { 361 return !el.hidden && !el.closest('[hidden]') && el.offsetParent !== null; 362 }); 363 } 364 function setBackgroundInert(on) { 365 try { 366 Array.prototype.forEach.call(document.body.children, function (el) { 367 if (el === modal || modal.contains(el) || el.contains(modal)) { return; } 368 if (on) { 369 if (el.hasAttribute('aria-hidden')) { el.setAttribute('data-fs-rp-aria-was', el.getAttribute('aria-hidden')); } 370 el.setAttribute('aria-hidden', 'true'); 371 if ('inert' in el) { el.inert = true; } 372 } else { 373 var was = el.getAttribute('data-fs-rp-aria-was'); 374 if (was === null) { el.removeAttribute('aria-hidden'); } else { el.setAttribute('aria-hidden', was); el.removeAttribute('data-fs-rp-aria-was'); } 375 if ('inert' in el) { el.inert = false; } 376 } 377 }); 378 } catch (e) { /* isolation is a nicety, never a blocker */ } 379 } 380 function trapTab(ev) { 381 if (ev.key !== 'Tab' || modal.hidden) { return; } 382 var list = focusables(); 383 if (!list.length) { return; } 384 var first = list[0], last = list[list.length - 1], active = document.activeElement; 385 if (ev.shiftKey && (active === first || !modal.contains(active))) { ev.preventDefault(); last.focus(); } 386 else if (!ev.shiftKey && (active === last || !modal.contains(active))) { ev.preventDefault(); first.focus(); } 387 } 388 function openModal() { 389 state.returnFocus = document.activeElement; 390 modal.hidden = false;
391 document.documentElement.classList.add('is-fsRpOpen'); 392 requestAnimationFrame(function () { modal.classList.add('is-open'); }); 393 setBackgroundInert(true); 394 document.addEventListener('keydown', trapTab, true); 395 // 1.2.0: focus lands on the panel itself (tabindex -1, set here for a template that lacks 396 // it), not on the first pill: no button lights up on open, and the visitor's first Tab goes 397 // to the first control in document order. trapTab still cycles inside the modal. 398 var panel = modal.querySelector('.fsRp-door-panel, .fsRp-wall-panel') || modal; 399 if (!panel.hasAttribute('tabindex')) { panel.setAttribute('tabindex', '-1'); } 400 try { panel.focus({ preventScroll: true }); } catch (e) { /* ignore */ } 401 } 402 function closeModal() { 403 modal.classList.remove('is-open'); 404 modal.hidden = true; 405 document.documentElement.classList.remove('is-fsRpOpen'); 406 document.removeEventListener('keydown', trapTab, true); 407 setBackgroundInert(false); 408 var back = state.returnFocus; 409 state.returnFocus = null; 410 if (back && back.focus && document.contains(back)) { try { back.focus({ preventScroll: true }); } catch (e) { /* ignore */ } } 411 } 412 // ---------------------------------------------------------------- the register form (door5) 413 // The board's own register form, loaded into .js-fsRpRegister. Nothing here 414 // builds a form or a field: the HTML is what /register answers, and XF's own 415 // handlers run it. The fallback (.js-fsRpFallback, the email pill) is shown 416 // whenever the form cannot be. 417 var REGISTER_TIMEOUT_MS = 12000; 418 // 1.2.4: how long a form already in the modal waits for XF's activation (the register page's 419 // assets) before the email pill is offered instead; see activationFailed. 420 var ACTIVATE_TIMEOUT_MS = 12000; 421 // 1.2.2: a prefetched form older than this still goes in at once on open (the fields show 422 // without a wait) and a fresh copy is fetched behind it; see loadRegisterForm. 423 var PREFETCH_MAX_AGE_MS = 600000; 424 // settled (1.2.2): the load reached its end, the form in the modal or the fallback shown. 425 var formState = { started: false, loaded: false, settled: false, submitted: false }; 426 // 1.2.2: the form fetched ahead of any click (prefetchRegisterForm). form is the parsed 427 // register form, NOT yet activated (1.2.4: parsed by this script the moment the HTML arrived, 428 // while XF.setupHtmlInsert loads the register page's JS and CSS behind it); complete(box) is 429 // the activation, run only once the form is in the modal (it waits for those assets, evals the 430 // page's inline scripts and hands the container to XF.activate); at is when it arrived; pending 431 // is the fetch in flight, so a click while it runs waits for it and never starts a second one. 432 var ready = { form: null, complete: null, at: 0, pending: null, preSettle: false }; 433 434 function registerBox() { return modal.querySelector('.js-fsRpRegister'); } 435 function registerUrl() { var box = registerBox(); return box ? (box.getAttribute('data-fs-rp-register-url') || '') : ''; } 436 function canFetchForm() { return !MEMBER_PREVIEW && registerUrl() !== '' && !!window.fetch && !!window.XF && !!XF.setupHtmlInsert && !!XF.activate; } 437 // The cache age rule, on its own so it is tested: older than PREFETCH_MAX_AGE_MS is stale. 438 function staleAfter(fetchedAt, now) { return now - fetchedAt > PREFETCH_MAX_AGE_MS; } 439 440 function showFallback() { 441 var box = registerBox(), fallback = modal.querySelector('.js-fsRpFallback'); 442 if (box) { box.hidden = true; box.setAttribute('aria-busy', 'false'); } 443 if (fallback) { fallback.hidden = false; } 444 formState.settled = true; 445 formState.loaded = false; 446 fieldsShown(); 447 } 448 function showFormErrors(data) { 449 var out = modal.querySelector('.js-fsRpRegisterErrors'); 450 if (!out) { return; } 451 out.textContent = ''; 452 var messages = []; 453 if (data && data.errors) { 454 var errs = data.errors; 455 if (Array.isArray(errs)) { messages = errs; } else if (typeof errs === 'object') { Object.keys(errs).forEach(function (k) { messages.push(errs[k]); }); } else { messages = [String(errs)]; } 456 } else if (data && data.exception) { 457 messages = [String(data.exception)]; 458 } 459 if (messages.length) { 460 var ul = document.createElement('ul');
461 messages.forEach(function (m) { var li = document.createElement('li'); li.textContent = String(m); ul.appendChild(li); }); 462 out.appendChild(ul); 463 out.hidden = false; 464 } else if (data && data.errorHtml && window.XF && XF.setupHtmlInsert) { 465 // A rendered error (XF's errorHtml): its markup, in our box, activated by XF. 466 XF.setupHtmlInsert(data.errorHtml, function (html, container, onComplete) { out.appendChild(html); out.hidden = false; onComplete(false, out); return false; }); 467 } else { 468 out.hidden = true; 469 return; 470 } 471 try { out.scrollIntoView({ block: 'nearest' }); } catch (e) { /* ignore */ } 472 } 473 474 // The fetched form, made ours in two small ways: the return path rides along as 475 // _xfRedirect (the RegisterController extension refreshes the session from it on 476 // the post), and fs_rp=1 marks the post as a prompt visit. Everything else in 477 // the form is XenForo's. 478 function prepareForm(form, returnTo) { 479 [['_xfRedirect', returnTo], ['fs_rp', '1']].forEach(function (pair) { 480 if (!pair[1]) { return; } 481 var input = document.createElement('input'); 482 input.type = 'hidden'; input.name = pair[0]; input.value = pair[1]; 483 form.appendChild(input); 484 }); 485 form.setAttribute('data-fs-rp-form', '1'); 486 // 1.2.4: the form is typeable before XF has activated it (fields first, insertRegisterForm). 487 // Until data-fs-rp-ready says the activation ran, a submit is refused here, in the capture 488 // phase ahead of XF's own ajax submit, so nothing ever posts without the register page's 489 // scripts (the timer, the captcha); holdSubmit keeps the buttons disabled meanwhile. This 490 // guard ends with the activation: from then on XF's own rules stand (XF.RegForm keeps the 491 // button disabled through its registration timer and until the terms box is ticked). 492 form.addEventListener('submit', function (ev) { 493 if (form.getAttribute('data-fs-rp-ready') !== '1') { ev.preventDefault(); ev.stopImmediatePropagation(); } 494 }, true); 495 // XF submits the form itself (data-xf-init="ajax-submit"). We record the 496 // submit once, and keep XF's error alerts inside the modal: an XF overlay 497 // would sit behind this dialog. 1.2.4: what was typed is captured at submit 498 // time too, for the re-fill below. 499 form.addEventListener('ajax-submit:before', function () { 500 formState.submitValues = captureValues(form); 501 if (!formState.submitted) { formState.submitted = true; end('provider_click', { provider: 'email', path: 'email' }); } 502 }); 503 form.addEventListener('ajax-submit:error', function (ev) { 504 ev.preventDefault(); 505 showFormErrors(ev.data || (ev.detail && ev.detail.data) || null); 506 }); 507 form.addEventListener('ajax-submit:response', function (ev) { 508 var data = ev.data || (ev.detail && ev.detail.data) || null; 509 if (data && data.redirect && isRegisterRedirect(data.redirect)) { 510 // 1.2.4: XF refused the registration key (RegisterController::actionRegister clears the 511 // state and redirects to a fresh /register; the session race). Not that redirect: a fresh 512 // form, re-filled with what was typed at submit time, so an early submit never loses input. 513 ev.preventDefault(); 514 recoverForm(form, formState.submitValues); 515 return; 516 } 517 if (data && !data.redirect && data.html && !data.errors && !data.errorHtml) { 518 // A rendered reply that is not the redirect (XF would open it as an 519 // overlay, behind us): show it in the error box instead. 520 ev.preventDefault(); 521 showFormErrors({ errorHtml: data.html }); 522 } 523 }); 524 } 525 526 // 1.2.4: the register HTML parsed by this script, the moment it arrives, so the form can go 527 // into the modal before the register page's assets: a template element (nothing runs), its 528 // scripts and noscripts dropped as XF.setupHtmlInsert drops them (the inline scripts still run, 529 // once, in XF's own onComplete at activation). 530 function parseRegisterHtml(content) { 531 var tpl = document.createElement('template'); 532 tpl.innerHTML = String(content).trim(); 533 Array.prototype.forEach.call(tpl.content.querySelectorAll('script, noscript'), function (el) { el.parentNode.removeChild(el); }); 534 return tpl.content; 535 } 536 // 1.2.4: the submit buttons held (disabled, the stylesheet's pending look) while the form waits 537 // for XF's activation, and released by it. Only OUR hold is ever released: a button the form 538 // came with disabled is not ours and is never touched (data-fs-rp-held marks the ones we 539 // disabled), and after the release XF's own rule for the Register button stands again: 540 // XF.RegForm (login_signup.js) keeps #js-signUpButton disabled
540while its registration timer 541 // (#js-regTimer) runs and until the terms box (#js-signUpDisabler) is ticked. Its handler is 542 // asked to recompute when XF.activate has given the form one; the same rule is applied by hand 543 // when it has not (Codex on 1.2.4: the first cut re-enabled Register under XF's timer, and an 544 // early submit sent the member back to a fresh form). 545 function holdSubmit(form, hold) { 546 Array.prototype.forEach.call(form.querySelectorAll('.formSubmitRow .button, button[type="submit"], input[type="submit"]'), function (b) { 547 if (hold) { 548 if (b.disabled) { return; } 549 b.disabled = true; 550 b.classList.add('is-disabled'); 551 b.setAttribute('data-fs-rp-held', '1'); 552 } else if (b.getAttribute('data-fs-rp-held') === '1') { 553 b.removeAttribute('data-fs-rp-held'); 554 b.classList.remove('is-disabled'); 555 b.disabled = false; 556 } 557 }); 558 if (!hold) { applyXfSubmitRule(form); } 559 } 560 function applyXfSubmitRule(form) { 561 var reg = (form.matches && form.matches('[data-xf-init~="reg-form"]')) ? form : form.querySelector('[data-xf-init~="reg-form"]'); 562 var handler = (reg && window.XF && XF.Element && XF.Element.getHandler) ? XF.Element.getHandler(reg, 'reg-form') : null; 563 if (handler && typeof handler.updateSubmitState === 'function') { handler.updateSubmitState(); return; } 564 var submit = form.querySelector('#js-signUpButton'), num = form.querySelector('#js-regTimer span'), disabler = form.querySelector('#js-signUpDisabler'); 565 if (submit && ((num && parseInt(num.textContent, 10) > 0) || (disabler && !disabler.checked))) { submit.disabled = true; } 566 } 567 568 // 1.2.4: what the member typed, and where the caret was, so a fresh copy of the form can take 569 // over without a keystroke lost (the pre-settle swap, the re-fill after XF refused a key). The 570 // register form's field names are hashed per registration key (RegisterFormService), so a field 571 // is known by its autocomplete purpose (username, email, new-password) and otherwise by its 572 // type and position; the honeypot rows (formRow--limited) and hidden fields are never carried. 573 function fieldKey(el, i) { 574 var ac = el.getAttribute('autocomplete'); 575 return (ac && ac !== 'off') ? 'ac:' + ac : 'field:' + (el.type || el.tagName.toLowerCase()) + ':' + i; 576 } 577 function carriedFields(form) { 578 return Array.prototype.filter.call(form.querySelectorAll('input, textarea, select'), function (el) { 579 return !(el.type === 'hidden' || el.type === 'submit' || el.type === 'button' || el.type === 'file' || el.closest('.formRow--limited')); 580 }); 581 } 582 function captureValues(form) { 583 var snap = { fields: {}, focus: null }; 584 carriedFields(form).forEach(function (el, i) { 585 var key = fieldKey(el, i); 586 snap.fields[key] = (el.type === 'checkbox' || el.type === 'radio') ? { checked: el.checked } : { value: el.value }; 587 if (el === document.activeElement) { snap.focus = { key: key, start: el.selectionStart, end: el.selectionEnd }; } 588 }); 589 return snap; 590 } 591 function restoreValues(form, snap) { 592 if (!form || !snap || !snap.fields) { return; } 593 var focus = null; 594 carriedFields(form).forEach(function (el, i) { 595 var key = fieldKey(el, i), had = snap.fields[key]; 596 if (!had) { return; } 597 if ('checked' in had) { el.checked = had.checked; } else if (el.value !== had.value) { el.value = had.value; } 598 if (snap.focus && snap.focus.key === key) { focus = el; } 599 }); 600 if (focus) { 601 try { focus.focus({ preventScroll: true }); } catch (e) { /* ignore */ } 602 if (snap.focus.start != null && focus.setSelectionRange) { try { focus.setSelectionRange(snap.focus.start, snap.focus.end); } catch (e) { /* an email field refuses a caret */ } } 603 } 604 } 605 606 // XF's own register form posts to <register route>/register. On a board whose register 607 // route is renamed by a route filter (TN Deer 2026-09-21: register -> login/register) the 608 // router writes that action as /login/register-register, so the form is known by the board's 609 // register path (data-fs-rp-register-url, the same router) plus "/register" or "-register"; 610 // only when the page carries no register path does the plain register/register test remain. 611 function isRegisterAction(action) { 612 var want = registerPathOf(registerUrl(), false); 613 if (want !== '' && want !== '/') { 614 var path = registerPathOf(action, false); 615 return path === want + '/register' || path === want + '-register'; 616 } 617 return /register\/register/.test(action); 618 } 619 function findRegisterForm(html) { 620 if (!html || !html.querySelector) { return null; } 621 var forms = html.matches && html.matches('form') ? [html] : Array.prototype.slice.call(html.querySelectorAll('form')); 622 for (var i = 0; i < forms.length; i++) { 623 var action = forms[i].getAttribute('action') || ''; 624 if (isRegisterAction(action) && !forms[i].querySelector('input[type="file"]')) { return forms[i]; } 625 } 626 return null; 627 } 628
629 // XF keeps a session as one row and writes it whole at the end of every page request, 630 // so the registration state /register puts in the session can be wiped by any XF 631 // request that started before the fetch and finished after it (on a board whose 632 // theme references a missing icon, every 404 is such a request). So the PREFETCH 633 // (1.2.2, ahead of any click) waits until the page has settled: after the load event, 634 // then 700 ms (Codex's reproduction with XF's own Session and RegisterFormService: an older 635 // request finishing 500 ms after load wipes the state a fetch at 150 ms had saved; at 700 the 636 // key survives). A click does not wait (Keith on rabbittalk: "a long delay before the fields 637 // show up"; on a page still loading ads the load event is seconds away): if the visitor opens 638 // the modal before the prefetch has run, the fetch starts at once and the skeleton stays only 639 // while it is in flight; since 1.2.4 the first pointerdown, touchstart or focus on a Register 640 // control starts it even earlier (the intent prefetch, below the click delegate). A form 641 // fetched BEFORE the settle point is marked pre-settle (its key may not survive): once the 642 // point passes, a fresh form is fetched quietly and swapped in, everything typed carried over 643 // (reconcileSettle, swapForm). And should a key still be refused, XF answers the submit with a 644 // redirect to a fresh /register (RegisterController::actionRegister clears the state); the 645 // form's response handler takes that as "fetch a fresh form and re-fill it" (recoverForm), so 646 // an early submit never loses what was typed. The email pill (showFallback) stays the way out 647 // when no form can be had at all. 648 var SETTLE_MS = 700; 649 var pageSettled = false; 650 function whenSettled(fn) { 651 var go = function () { setTimeout(fn, SETTLE_MS); }; 652 if (document.readyState === 'complete') { go(); } else { window.addEventListener('load', go, { once: true }); } 653 } 654 function onSettled() { 655 pageSettled = true; 656 reconcileSettle(); 657 } 658 // The settle point has passed: a pre-settle form, ready or already in the modal, is replaced 659 // by a fresh one; with nothing pre-settle about, the ordinary prefetch. Called again whenever 660 // a fetch lands (startFormFetch), so a fetch in flight is simply waited for. 661 function reconcileSettle() { 662 if (!pageSettled || ready.pending) { return; } 663 var box = registerBox(), inserted = box ? box.querySelector('form[data-fs-rp-presettle="1"]') : null; 664 if (ready.form && !ready.preSettle) { 665 if (inserted) { swapForm(box, inserted, takeReady()); } 666 return; 667 } 668 if (inserted || ready.form) { startFormFetch().then(null, function () { /* the pre-settle form stays; its key may still pass */ }); return; } 669 // 1.2.9: nothing fetched before the settle point means nobody reached for the form; the 670 // page does not prefetch on its own any more (see prefetchRegisterForm). 671 } 672 // The fresh form takes the pre-settle one's place, with everything typed and the caret. 673 function swapForm(box, oldForm, entry) { 674 var snap = captureValues(oldForm); 675 insertRegisterForm(box, entry, box.getAttribute('data-fs-rp-return') || ''); 676 restoreValues(box.querySelector('form'), snap); 677 } 678 // XF refused the key on submit: a fresh form, re-filled from the submit-time capture. A fetch 679 // already in flight (the settle refresh) is joined, never doubled. Whoever lands first installs 680 // the fresh form: reconcileSettle runs on the landing before this callback, and when it has 681 // already swapped the fresh form in (the box holds a form that is not the one submitted) there 682 // is nothing left to do; the ready slot is not the judge (Codex round 2: the settle refresh had 683 // consumed it and the re-filled form was hidden behind the email pill). 684 function recoverForm(oldForm, snap) { 685 var box = registerBox(); 686 if (!box) { return; } 687 ready.form = null; ready.complete = null; ready.preSettle = false; 688 (ready.pending || startFormFetch()).then(function (entry) { 689 var installed = box.querySelector('form'); 690 if (installed && installed !== oldForm) { return; } // reconcileSettle already put the fresh form in, what was typed carried over 691 if (!ready.form || ready.form !== entry.form) { showFallback(); return; } 692 insertRegisterForm(box, takeReady(), box.getAttribute('data-fs-rp-return') || ''); 693 restoreValues(box.querySelector('form'), snap); 694 }, function () { showFallback(); }); 695 } 696 // A redirect XF sends to the register page itself (not register/complete, not anywhere else). 697 function isRegisterRedirect(url) { 698 var want = registerPathOf(registerUrl(), false); 699 return want !== '' && want !== '/' && registerPathOf(String(url || ''), false) === want; 700 } 701 702 // The fetch itself: resolves with { form, complete } (the parsed register form, not yet 703 // activated, and the activation to run on it later), rejects on anything else. 704 function fetchRegisterHtml(url) { 705 // The same request XF.ajax makes for data-xf-click="overlay" (js/xf/core.js). 706 var params = new URLSearchParams(); 707 params.set('_xfRequestUri', location.pathname + location.search); 708 params.set('_xfWithData', '1'); 709 if (XF.config && XF.config.csrf) { params.set('_xfToken', XF.config.csrf); } 710 params.set('_xfResponseType', 'json'); 711 var controller = window.AbortController ? new AbortController() : null; 712 var timer = setTimeout(function () { if (controller) { controller.abort(); } }, REGISTER_TIMEOUT_MS); 713 return fetch(url + (url.indexOf('?') >= 0 ? '&' : '?') + params.toString(), { 714 method: 'GET', 715 credentials: 'same-origin', 716 headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, 717 signal: controller ? controller.signal : undefined 718 }).then(function (response) { 719 if (!response.ok) { throw new Error('http ' + response.status); } 720 return response.json(); 721 }).then(function (data) { 722 clearTimeout(timer); 723 if (!data || !data.html || !data.html.content) { throw new Error('no form'); } 724 // 1.2.4: the form is ours the moment the HTML is here (parseRegisterHtml), so it can go 725 // into the modal at once. setupHtmlInsert then loads the JS and CSS the register page 726 // declares (the timer, the password meter, the one-column stylesheet) and hands over its 727 // onComplete, kept, not called: the activation (the inline scripts, XF.activate on the 728 // container we name) runs on the form already in the modal (insertRegisterForm). 729 var form = findRegisterForm(parseRegisterHtml(data.html.content)); 730 if (!form) { throw new Error('no form'); } 731 var activation = new Promise(function (resolve) { 732 XF.setupHtmlInsert(data.html, function (html, container, onComplete) { resolve(onComplete); return false; }); 733 }); 734 return { form: form, complete: function (box) { return activation.then(function (onComplete) { onComplete(false, box); }); } }; 735 }).catch(function (e) { clearTimeout(timer); throw e; }); 736 } 737 738 // One fetch at a time; what arrives waits in ready until a modal open takes it. 1.2.4: a fetch 739 // that STARTED before the settle point is marked pre-settle (see SETTLE_MS), and every landing 740 // asks reconcileSettle whether a fresh copy is now due. 741 function startFormFetch() { 742 var preSettle = !pageSettled; 743 var p = fetchRegisterHtml(registerUrl()).then(function (entry) { ready.form = entry.form; ready.complete = entry.complete; ready.preSettle = preSettle; ready.at = Date.now(); return entry; }); 744 ready.pending = p; 745 p.then(function () { if (ready.pending === p) { ready.pending = null; } reconcileSettle(); }, function () { if (ready.pending === p) { ready.pending = null; } }); 746 return p; 747 } 748 749 // 1.2.2 fetched the form once ahead of any click, once the page had settled, on EVERY page 750 // that carried the modal. 1.2.9 (Keith 2026-09-21, Candle Power): that register-page load runs 751 // the board's signup-abuse checks (StopForumSpam, Project Honey Pot, ipregistry) for every 752 // guest who merely reads a thread, thousands a day, most of them never seeing the modal; 753 // Candle Power's paid ipregistry key ran dry in two days (58,000 "HTTP status 402" rows a day). 754 // So the form is now fetched only for a visitor who reaches for it: the intent prefetch (the 755 // first pointerdown, touchstart or focus on a Register control,
7551.2.4), a click that opens the 756 // modal, or the automatic open itself (loadRegisterForm). The settle mechanics stay for a form 757 // fetched by intent before the settle point. Nothing is activated until a click puts the form 758 // in the modal, so a page that is never opened runs none of the register page's scripts. 759 function prefetchRegisterForm() { 760 if (ready.pending || ready.form || formState.started || !canFetchForm()) { return; } 761 startFormFetch(); 762 } 763 764 // 1.2.4: fields first. The form goes into the modal and is typeable from here (the fields 765 // are what latency_ms measures); its submit is held. XF's activation (the register page's 766 // assets, its inline scripts, XF.activate on this container) then runs on these very same 767 // elements in place, nothing is re-inserted so nothing typed is lost, and the submit is 768 // released; the captcha may still be drawing itself after that, which is fine. Should the 769 // activation never come, activationFailed offers the email pill. 770 function insertRegisterForm(box, entry, returnTo) { 771 var form = entry.form; 772 prepareForm(form, returnTo); 773 if (entry.preSettle) { form.setAttribute('data-fs-rp-presettle', '1'); } // replaced once the page has settled (reconcileSettle) 774 holdSubmit(form, true); 775 while (box.firstChild) { box.removeChild(box.firstChild); } 776 box.appendChild(form); 777 box.hidden = false; 778 box.setAttribute('aria-busy', 'false'); 779 formState.loaded = true; 780 formState.settled = true; 781 // The form states the terms itself (a checkbox or the policy sentence): one statement is enough. 782 var terms = modal.querySelector('.js-fsRpTerms');
783 if (terms && (form.querySelector('.fsReg-policy') || form.querySelector('input[name="accept"]'))) { terms.hidden = true; } 784 fieldsShown(); 785 var released = false; 786 var release = function () { 787 if (released || !box.contains(form)) { return; } 788 released = true; 789 form.setAttribute('data-fs-rp-ready', '1'); 790 holdSubmit(form, false); 791 }; 792 entry.complete(box).then(release, function () { activationFailed(box, form); }); 793 setTimeout(function () { if (!released) { activationFailed(box, form); } }, ACTIVATE_TIMEOUT_MS); 794 } 795 // 1.2.4: the activation never came (a register page asset that failed to load, a network 796 // slower than ACTIVATE_TIMEOUT_MS). The form must not post without the page's scripts, so 797 // the email pill is offered: alone (showFallback, the 1.1.0 path, unchanged) while nothing has 798 // been typed; under the held form when something has, so what was typed stays on screen. 799 function activationFailed(box, form) { 800 if (form.getAttribute('data-fs-rp-ready') === '1' || !box.contains(form)) { return; } 801 if (formUntouched(box)) { showFallback(); return; } 802 var fallback = modal.querySelector('.js-fsRpFallback'); 803 if (fallback) { fallback.hidden = false; } 804 } 805 function takeReady() { 806 var entry = { form: ready.form, complete: ready.complete, preSettle: ready.preSettle }; 807 ready.form = null; ready.complete = null; ready.preSettle = false; 808 return entry; 809 } 810 // Nothing typed, nothing focused: a fresh copy may replace the form without taking anything away. 811 function formUntouched(box) { 812 if (box.contains(document.activeElement)) { return false; } 813 return Array.prototype.every.call(box.querySelectorAll('input, textarea, select'), function (el) { 814 if (el.type === 'hidden' || el.type === 'submit' || el.type === 'button') { return true; } 815 if (el.type === 'checkbox' || el.type === 'radio') { return el.checked === el.defaultChecked; } 816 if (el.tagName === 'SELECT') { return el.selectedIndex === 0 || el.options[el.selectedIndex].defaultSelected; } 817 return el.value === el.defaultValue; 818 }); 819 } 820 821 function loadRegisterForm() { 822 var box = registerBox(); 823 if (!box || formState.started) { return; } 824 formState.started = true; 825 var url = box.getAttribute('data-fs-rp-register-url') || ''; 826 var returnTo = box.getAttribute('data-fs-rp-return') || ''; 827 if (MEMBER_PREVIEW || !url || !window.fetch || !window.XF || !XF.setupHtmlInsert || !XF.activate) { showFallback(); return; } 828 // No provider on this board: the "or" between the pills and the form has nothing to join.
829 var providers = modal.querySelector('.fsRp-door5-providers'), or = modal.querySelector('.fsRp-door5-or'); 830 if (or && providers && !providers.querySelector('.js-fsRpPill')) { or.hidden = true; } 831 // 1.2.2: a prefetched form goes in at once. Older than PREFETCH_MAX_AGE_MS: still at once, 832 // and a fresh copy is fetched behind it; it replaces this one only while nothing has been 833 // typed (a closed modal, or an open one nobody has touched). 834 if (ready.form) { 835 var stale = staleAfter(ready.at, Date.now()); 836 insertRegisterForm(box, takeReady(), returnTo); 837 if (stale) { 838 startFormFetch().then(function () { if (ready.form && formUntouched(box)) { insertRegisterForm(box, takeReady(), returnTo); } }, function () { /* the form in place stays */ }); 839 } 840 return; 841 } 842 // No form ready: the prefetch in flight, or a fetch started right now (a click never waits 843 // for the load event). The skeleton stays only while the fetch is in flight. 844 (ready.pending || startFormFetch()).then(function () { if (ready.form) { insertRegisterForm(box, takeReady(), returnTo); } else { showFallback(); } }, function () { showFallback(); }); 845 } 846 function inRegisterField(el) { 847 return !!(el && el.closest && el.closest('.js-fsRpRegister') && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); 848 } 849 850 // The X, the Escape key or a scrim tap: closed without saying "No thanks". 851 // Records the dismissal under its own reason (user_close / user_escape); the 852 // long suppression stays with the explicit button. 853 function softClose(reason) { 854 end('dismiss', { dismiss_reason: reason || 'user_close' }); 855 deactivate(); 856 state.reason = 'closed'; 857 } 858 // source: 'auto' (the gates opened it) or 'cta' (a deliberate click on the page asked 859 // for it: no caps, no floor, no dismissal check, not counted toward the caps). origin 860 // (1.2.1, cta only) names what was clicked: notice, nav, link or other (see ORIGINS). 861 function activate(adResolution, source, origin) { 862 var onDemand = source === 'cta'; 863 if (CRAWLER && !onDemand && !PREVIEW) { return; } // 1.2.4: a crawler never gets an automatic open, nor its impression 864 if (state.shown || (!onDemand && suppressedNow())) { return; } 865 state.shown = true; 866 state.reason = onDemand ? 'shown_cta' : 'shown'; 867 visitorId(); 868 openModal(); 869 if (!PREVIEW && !onDemand) { recordSeen(); } 870 // 1.2.2: a click-opened door5 records its impression when the fields show (fieldsShown), with 871 // latency_ms = click to fields; a form (or the fallback) already in the modal counts as shown at once. 872 if (onDemand && registerBox() && !formState.settled) { 873 pendingImpression = { extra: { source: 'cta', origin: originOf(origin) }, at: performance.now() }; 874 } else { 875 beacon('impression', onDemand ? { source: 'cta', origin: originOf(origin), latency_ms: 0 } : { ad_gate_resolution: adResolution }); 876 } 877 loadRegisterForm(); // door5 only: a no-op without the .js-fsRpRegister container 878 // Idle marker only: records that nothing happened in the first 879 // noInteractionMs. The modal stays open and every later action, a pill, 880 // "No thanks", Escape, still records and still works. 881 state.timer = setTimeout(function () { state.timer = null; state.idle = true; beacon('dismiss', { dismiss_reason: 'idle' }); }, cfg.noInteractionMs); 882 } 883 884 // ---------------------------------------------------------------- on demand 885 // Any element on the page can open the prompt: data-fs-rp-open on anything, or 886 // class js-fsRpOpen on a link to the board's own /register/ (j3-cub's guest 887 // notice "Subscribe now" button, for one). Since 1.1.1, with the board option 888 // fsRegisterPromptHijackRegisterLinks on (the default), a plain link to 889 // /register/ opens it too when it sits inside a XenForo notice or the header's 890 // register control (HIJACK_ZONES below), so the notices Keith's boards already 891 // carry ("Subscribe now", "Register") work without editing 600 boards. A 892 // register link anywhere else (in a post, a signature, the footer) is left to 893 // navigate. The click is a guest's deliberate ask, so it skips every timing 894 // gate and every cap and is not counted toward them; it still records an 895 // impression, marked source=cta. A member is never here (the server renders
896 // no modal for a member outside a preview, so this script has already 897 // returned), and in a member preview the link is left to navigate. When the 898 // server did not render the modal (kill switch, registration closed, 899 // the login, register and lost-password pages) this script is not running at 900 // all, so the click falls through to the link as before. Since 1.1.3 a page 901 // the exclusion rules name (the forum home, for one) still carries the modal 902 // in cta mode, so the notice button there opens it too. 903 // 904 // 1.2.1: every on-demand impression also says WHAT was clicked, as origin 905 // (ORIGINS below): the clicked element's data-fs-rp-origin when it carries one 906 // (Keith's Registration Notice sets "notice" on its button); else, for a 907 // register link this script hijacked or a js-fsRpOpen link, "nav" when the 908 // link sits in the header (NAV_ORIGIN_ZONES) and "link" anywhere else; else 909 // "other" (a bare data-fs-rp-open element, window.FsRegisterPrompt.open with 910 // no origin). The server stores it as error_code = "cta:<origin>" on the 911 // impression row; a gate-driven open keeps null, as it always has. 912 function openOnDemand(source, origin) { 913 if (MEMBER_PREVIEW) { return false; } 914 if (state.shown && !modal.hidden) { return true; } 915 // Shown before on this page and closed: open it again, as a fresh impression. 916 state.shown = false; state.ended = false; state.idle = false; 917 if (state.timer) { clearTimeout(state.timer); state.timer = null; } 918 activate(null, source === 'auto' ? 'auto' : 'cta', origin); 919 return state.shown; 920 } 921 // Where a plain register link counts (XF's notice_macros and PAGE_CONTAINER): 922 // .notices / .notice ul.notices.notices--{block|scrolling|floating|bottom_fixer} > li.notice 923 // .p-nav .p-navgroup .p-account the header nav and its guest group (a.p-navgroup-link--register) 924 // .p-offCanvasRegisterLink the same Register link in the phone off-canvas menu (under 360px) 925 var HIJACK_ZONES = '.notices, .notice, .p-nav, .p-navgroup, .p-account, .p-offCanvasRegisterLink'; 926 // 1.2.1: the origins the server accepts (Pub/Controller/Prompt.php ORIGINS), and where a 927 // register link counts as the header's own Register control ("nav"): the header nav, its 928 // groups, XF's section links, and the same link in the phone off-canvas menu. 929 var ORIGINS = ['notice', 'nav', 'link', 'other']; 930 var NAV_ORIGIN_ZONES = '.p-nav, .p-navgroup, .p-sectionLinks, .p-offCanvasRegisterLink'; 931 function originOf(origin) { return ORIGINS.indexOf(origin) >= 0 ? origin : 'other'; } 932 function originOfElement(el, viaRegisterLink) { 933 var declared = el.getAttribute('data-fs-rp-origin'); 934 if (declared && ORIGINS.indexOf(declared) >= 0) { return declared; } 935 if (viaRegisterLink) { return el.closest(NAV_ORIGIN_ZONES) ? 'nav' : 'link'; } 936 return 'other'; 937 } 938 // The path part of a register URL, in one shape whichever way the link was 939 // written: "/register/", "/register", "/index.php?register/" and 940 // "/index.php?register" all give "/register" (a board under /forum/ gives 941 // "/forum/register"). The query (_xfRedirect, fs_rp) is not part of it, and 942 // a clicked link counts only on the page's own origin (a link to another 943 // site's /register/ is just a link). 944 function registerPathOf(href, sameOriginOnly) { 945 try { 946 var u = new URL(href, location.href); 947 if (sameOriginOnly && u.origin !== location.origin) { return ''; } 948 var path = u.pathname, first = u.search ? u.search.slice(1).split('&')[0] : ''; 949 if (/\/index\.php$/i.test(path) && first && first.indexOf('=') < 0) { 950 path = path.slice(0, -'index.php'.length) + decodeURIComponent(first); 951 } 952 return path.replace(/\/+$/, '') || '/'; 953 } catch (e) { return ''; } 954 } 955 function isRegisterLink(a) { 956 var box = registerBox(), email = modal.querySelector('.js-fsRpEmail'); 957 var registerUrl = (box && box.getAttribute('data-fs-rp-register-url')) || (email && email.getAttribute('href')) || ''; 958 var want = registerPathOf(registerUrl, false); 959 // a.href is the resolved URL (the page's <base href> honoured, as the browser would). 960 return want !== '' && want !== '/' && registerPathOf(a.href || '', true) === want; 961 } 962 // The one rule for what opens the modal on a click (1.2.4: and what starts the intent 963 // prefetch): the element the event landed in that carries data-fs-rp-open, else the link it 964 // landed in when that is a register link with class js-fsRpOpen or (option on) inside a 965 // hijack zone. Nothing inside the modal, nothing else. 966 function openerOf(target) { 967 if (!target || !target.closest) { return null; } 968 var el = target.closest('[data-fs-rp-open]') || target.closest('a[href]'); 969 if (!el || modal.contains(el)) { return null; } 970 if (!el.hasAttribute('data-fs-rp-open')) { 971 if (!isRegisterLink(el)) { return null; } 972 if (!el.classList.contains('js-fsRpOpen') && !(cfg.hijackRegisterLinks && el.closest(HIJACK_ZONES))) { return null; } 973 } 974 return el; 975 } 976 // Capture phase, so this runs before XF's own delegated click handlers (a register 977 // link usually carries data-xf-click="overlay"); when the modal opens, the event 978 // stops here so XF does not open its overlay on top of it. 979 document.addEventListener('click', function (ev) { 980 if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) { return; } 981 var el = openerOf(ev.target); 982 if (!el) { return; } 983 var viaRegisterLink = !el.hasAttribute('data-fs-rp-open'); 984 if (openOnDemand('cta', originOfElement(el, viaRegisterLink))) { 985 ev.preventDefault(); ev.stopPropagation(); 986 // The phone menu's Register link: the menu would otherwise stay open behind the modal. 987 var offCanvas = el.closest('.offCanvasMenu'); 988 if (offCanvas && window.XF && XF.trigger) { try { XF.trigger(offCanvas, 'off-canvas:close'); } catch (e) { /* cosmetic */ } } 989 } 990 }, true); 991 // 1.2.4: the intent prefetch. The automatic prefetch waits for the page to settle; a visitor
992 // who reaches for Register before that (ihcubcadet: the header link on the forum home, before 993 // the post-settle prefetch had run) used to wait for the whole fetch. The first pointerdown, 994 // touchstart or focus on anything the click delegate would open the modal for starts the 995 // fetch right then, before the click lands, through the same single-fetch guard as every 996 // other prefetch (prefetchRegisterForm); a click that arrives before it resolves shows the 997 // skeleton only for the time still in flight. Passive and in the capture phase: it never 998 // delays or alters the click. 999 ['pointerdown', 'touchstart', 'focusin'].forEach(function (type) { 1000 document.addEventListener(type, function (ev) { if (openerOf(ev.target)) { prefetchRegisterForm(); } }, { capture: true, passive: true }); 1001 }); 1002 1003 // The final check-and-activate runs under a cross-tab lock where the browser 1004 // has one, so two tabs finishing their gates in the same tick cannot both 1005 // show. Without Web Locks the storage event below is the (weaker) fallback. 1006 function activateExclusive(adResolution) { 1007 var run = function () { 1008 // The visible gate passed once, possibly seconds ago: a tab that went to 1009 // the background since must not open (and count) an unseen modal. 1010 if (document.prerendering || document.visibilityState !== 'visible') { 1011 var waitingSince = Date.now(); 1012 visibleGate().then(function () { 1013 var seen = seenList(); 1014 if (seen.length && Math.max.apply(null, seen) >= waitingSince) { state.reason = 'shown_elsewhere'; return; } 1015 activateExclusive(adResolution); 1016 }); 1017 return; 1018 } 1019 if (!suppressedNow()) { activate(adResolution); } 1020 }; 1021 if (navigator.locks && navigator.locks.request) { 1022 navigator.locks.request(PREFIX + 'fs_rp_gate', { ifAvailable: true }, function (lock) { if (lock) { run(); } return Promise.resolve(); }).catch(run); 1023 } else { 1024 run(); 1025 } 1026 } 1027 1028 // arm() is the only path to a prompt nobody asked for. In cta mode (AUTO false) 1029 // it is never called: not at load, not after a bfcache restore. A preview 1030 // (?fs_rp_preview=1) still opens, for looking at the modal on any page. 1031 function arm() { 1032 if (state.shown || (!AUTO && !PREVIEW)) { return; } 1033 if (PREVIEW) { activate('preview'); return; } 1034 if (suppressedNow()) { return; } 1035 if (searchLanding()) { state.reason = 'search_landing'; return; } 1036 visitorId(); // mint early so the member_created row can carry it 1037 Promise.all([visibleGate(), floorGate(), adGate()]).then(function (results) { activateExclusive(results[2]); }); 1038 } 1039 1040 // "No thanks". Records the explicit dismissal, suppresses for dismissTtlDays 1041 // on this device, and takes the prompt down. 1042 function dismiss() { 1043 end('dismiss', { dismiss_reason: 'user_no_thanks' }); 1044 if (!PREVIEW) { writeBoth(K_DISMISS, String(Date.now() + cfg.dismissTtlDays * 86400 * 1000), cfg.dismissTtlDays); } 1045 deactivate(); 1046 state.reason = 'dismissed'; 1047 } 1048 1049 window.addEventListener('pagehide', function () { end('dismiss', { dismiss_reason: 'page_unload' }); }); 1050 1051 // bfcache: a suspended timer would fire minutes late with a lying latency. 1052 window.addEventListener('pageshow', function (ev) { 1053 if (!ev.persisted) { return; } 1054 if (state.timer) { clearTimeout(state.timer); state.timer = null; } 1055 if (!state.shown) { arm(); } 1056 }); 1057 1058 // Cross-tab: another tab's write lands here immediately. 1059 window.addEventListener('storage', function (ev) { 1060 if (!ev.key || (ev.key !== PREFIX + K_DISMISS && ev.key !== PREFIX + K_SEEN)) { return; } 1061 if (suppressedNow()) { deactivate(); } 1062 }); 1063 1064 modal.addEventListener('click', function (ev) { 1065 // The email pill (a GET link to /register, first in the list) or a 1066 // provider pill (a POST form to XF's connected-account setup): record the 1067 // click (provider_click, provider=email path=email for the email pill) and 1068 // let the browser go. 1069 var button = ev.target.closest('.js-fsRpPill, .js-fsRpEmail'); 1070 if (button) { 1071 if (MEMBER_PREVIEW) { ev.preventDefault(); return; } 1072 var provider = button.getAttribute('data-fs-rp-provider') || 'other'; 1073 end('provider_click', { provider: provider, path: provider === 'email' ? 'email' : 'social' }); 1074 return; // the form posts / the link navigates 1075 } 1076 if (ev.target.closest('[data-fs-rp-close]')) { ev.preventDefault(); softClose('user_close'); return; } 1077 if (ev.target.closest('[data-fs-rp-no]')) { ev.preventDefault(); dismiss(); return; } 1078 if (ev.target.closest('[data-fs-rp-scrim]')) { softClose('user_escape'); } 1079 }); 1080 // Dismissal is a UI concern, never an analytics one: softClose() is safe to
1081 // call after the outcome is recorded (end() no-ops, deactivate() still runs). 1082 // Escape closes the dialog, except while typing in the register form: a half-typed form must not vanish on one key. 1083 document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && state.shown && !inRegisterField(ev.target)) { softClose('user_escape'); } }); 1084 // Preview switcher: buttons (and [ / ] keys) reload straight into another 1085 // variant; Hide/Show toggles this one without recording anything. 1086 var switcher = document.querySelector('.js-fsRpSwitch'); 1087 if (switcher && PREVIEW) { 1088 var order = Array.prototype.map.call(switcher.querySelectorAll('[data-fs-rp-switch]'), function (b) { return b.getAttribute('data-fs-rp-switch'); }); 1089 var art = /[?&]fs_rp_art=([^&]*)/.exec(location.search); 1090 var go = function (v) { var arm = v + (armHeadline && /^door/.test(v) ? ':' + armHeadline : ''); location.href = location.pathname + '?fs_rp_preview=1&fs_rp_variant=' + encodeURIComponent(arm) + (art ? '&fs_rp_art=' + art[1] : ''); }; 1091 var toggle = switcher.querySelector('[data-fs-rp-switch-toggle]'); 1092 var hideLabel = toggle.textContent, showLabel = toggle.getAttribute('data-show-label') || 'Show'; 1093 var reopen = function () { state.shown = false; state.ended = false; if (state.timer) { clearTimeout(state.timer); state.timer = null; } activate('preview'); }; 1094 var syncToggle = function () { toggle.textContent = modal.hidden ? showLabel : hideLabel; }; 1095 var exit = switcher.querySelector('[data-fs-rp-switch-exit]'); if (exit) { exit.setAttribute('href', location.pathname); } 1096 switcher.addEventListener('click', function (ev) { 1097 var b = ev.target.closest('[data-fs-rp-switch]'); if (b) { go(b.getAttribute('data-fs-rp-switch')); return; } 1098 if (ev.target.closest('[data-fs-rp-switch-toggle]')) { if (modal.hidden) { reopen(); } else { deactivate(); } syncToggle(); } 1099 }); 1100 document.addEventListener('keydown', function (ev) { 1101 if (ev.target && /^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) { return; } 1102 var i = order.indexOf(baseVariant); 1103 if (ev.key === ']') { go(order[(i + 1) % order.length]); } else if (ev.key === '[') { go(order[(i - 1 + order.length) % order.length]); } 1104 }); 1105 } 1106 window.FsRegisterPrompt = { open: openOnDemand, dismiss: dismiss, close: function () { softClose('user_close'); }, reason: function () { return state.reason; }, version: cfg.v, auto: AUTO }; 1107 1108 // 1.2.2: the register form is fetched ahead of any click, in both modes, once the page has settled. 1109 whenSettled(onSettled); // the settle point: a fresh copy for a form fetched by intent before it (1.2.9: no prefetch of its own) 1110 1111 // cta mode (1.1.3): the modal waits for a click; nothing is armed at load. 1112 if (!AUTO && !PREVIEW) { state.reason = 'cta_only'; return; } 1113 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', arm); } else { arm(); } 1114})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.