PageSourceSearch

https://www.mychevybolt.com/js/forum_systems/register_prompt/prompt.js?_v=0542b79d

js mychevybolt.com collected 2026-10-02 19:12:12 UTC 67,200 bytes, 1,114 lines download raw bytes

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.