1// Function to generate a random ID 2function generateID() { 3 return '_' + Math.random().toString(36).substr(2, 9); 4} 5 6// Function to get cookie by name 7function getCookie(name) { 8 return document.cookie.replace(new RegExp(`(?:(?:^|.*;\\s*)${name}\\s*\\=\\s*([^;]*).*$)|^.*$`), "$1"); 9} 10 11// Function to set cookie 12function setCookie(name, value, days) { 13 const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : ""; 14 document.cookie = `${name}=${value}${expires}; path=/`; 15} 16 17// The <script> element this file was loaded through. document.currentScript is 18// only valid during initial script execution, which is when these helpers run. 19function getScriptElement() { 20 return document.currentScript || (function() { 21 const scripts = document.getElementsByTagName('script'); 22 return scripts[scripts.length - 1]; 23 })(); 24} 25 26// Function to get query parameter from script src 27function getScriptQueryParam(key) { 28 const params = new URLSearchParams(getScriptElement().src.split('?')[1]); 29 return params.get(key); 30} 31 32// Function to get a data-* attribute from the script tag 33function getScriptAttribute(name) { 34 const el = getScriptElement(); 35 return el ? el.getAttribute(name) : null; 36} 37 38// Initialize User and Session IDs 39let userID = getCookie('userID'); 40let sessionID = getCookie('sessionID'); 41 42if (!userID) { 43 userID = generateID(); 44 setCookie('userID', userID, 3650); // expires in 10 years 45} 46 47if (!sessionID) { 48 sessionID = generateID(); 49 setCookie('sessionID', sessionID, 1); // Session cookie, expire in 1 day 50} 51 52// Optionally get the pageID 53const pageID = getScriptQueryParam('pageID'); 54 55// Where this visitor was redirected to: set by nginx (@default_fallback / fb 56// redirect) to the variant they were funnelled into (e.g. "vpn/", "manyvoices/zh", 57// "fb"). Absent for visitors who arrive directly at a landing URL. Sent once with 58// the initial pageview, then cleared so refreshes/later views count as direct. 59const redirectedTo = getCookie('smm_redirected_to'); 60 61// Banner A/B test, round 3. 'card' is the corner card that has now won two 62// rounds; 'sheet' keeps that card on desktop and on phones anchors it to the 63// bottom edge as a native-style sheet with a larger tap target. Round 2 tried 64// to fix desktop and found there was nothing there to fix â roughly nine in ten 65// desktop impressions are automated â so this round only moves mobile, where 66// interaction-verified visitors click at about 13%. Sticky per visitor so 67// nobody sees both arms, and recorded on every pageview (see below) so each arm 68// has an impressions denominator to divide its clicks by. A new cookie name 69// again: reusing smm_bv2 would pin every round 2 visitor to the arm they 70// already had. 71let bannerVariant = getCookie('smm_bv3'); 72if (bannerVariant !== 'card' && bannerVariant !== 'sheet') { 73 bannerVariant = Math.random() < 0.5 ? 'card' : 'sheet'; 74 setCookie('smm_bv3', bannerVariant, 90); 75} 76 77// First segment of the paths the banner links to (see fbPath / vpnPath below â 78// keep in step with them). Anywhere inside those sections the banner would be 79// advertising the destination the visitor is already reading, so it is not 80// shown at all. Both arms are suppressed on the same sections, otherwise the 81// two arms would be measured over different pages. Decided here rather than in 82// the banner code because the pageview below has to know: a suppressed view is 83// not an impression and must not land in either arm's denominator. 84const BANNER_DESTINATIONS = ['fb', 'vpn']; 85const bannerSuppressed = BANNER_DESTINATIONS.indexOf( 86 (window.location.pathname.split('/').filter(p => p !== '')[0] || '').toLowerCase() 87) !== -1; 88 89// Operator opt-out: data-banner="false" on the <script> tag turns the banner 90// off for that mirror. Read here, ahead of the banner code, for the same 91// reason as bannerSuppressed above â the pageview below must tag these views 92// 'bv:none' so they never land in an arm's impression denominator. 93const bannerDisabled = getScriptAttribute('data-banner') === 'false'; 94 95// Campaign tags the visitor arrived with, captured before they are stripped from 96// the address bar (see stripUtmFromUrl below). Duplicates are collapsed to the 97// first value: URLs in circulation carry the same tag repeated many times. 98const arrivalUtm = (() => { 99 const kept = new URLSearchParams();
100 new URLSearchParams(window.location.search).forEach((value, key) => { 101 if (key.lastIndexOf('utm_', 0) === 0 && !kept.has(key)) kept.set(key, value); 102 }); 103 return kept.toString(); 104})(); 105 106// Later events on this page (hd/hd2 probes, banner clicks) fire after the tags 107// have left the URL, so re-attach them: the admin's Banner Clicks panel matches 108// on pagePath and its HD columns depend on those events carrying the campaign. 109function currentPagePath() { 110 const path = window.location.pathname + window.location.search; 111 if (!arrivalUtm || path.indexOf('utm_') !== -1) return path; 112 return path + (path.indexOf('?') === -1 ? '?' : '&') + arrivalUtm; 113} 114 115// utm_* to gtag campaign field, per the Google tag API. 116const UTM_TO_CAMPAIGN_FIELD = { 117 utm_id: 'id', 118 utm_source: 'source', 119 utm_medium: 'medium', 120 utm_campaign: 'name', 121 utm_term: 'term', 122 utm_content: 'content' 123}; 124 125// A destination's own Google tag reads the campaign off the URL when its hit 126// goes out, which is seconds after the tags have to be gone, so hand it the 127// campaign explicitly instead. gtag only applies `set` to commands queued after 128// it and the page's own `config` â the one that triggers the page_view â is 129// already queued by the time this file runs, hence the unshift. No-op on pages 130// without a Google tag. 131function passUtmToGoogleTag() { 132 if (!arrivalUtm) return; 133 const tags = new URLSearchParams(arrivalUtm); 134 const campaign = {}; 135 Object.keys(UTM_TO_CAMPAIGN_FIELD).forEach(key => { 136 if (tags.get(key)) campaign[UTM_TO_CAMPAIGN_FIELD[key]] = tags.get(key); 137 }); 138 if (Object.keys(campaign).length === 0) return; 139 // Queue entries are arguments objects, so mimic one exactly rather than 140 // pushing an array, which gtag would ignore. 141 const command = (function () { return arguments; })('set', 'campaign', campaign); 142 window.dataLayer = window.dataLayer || []; 143 window.dataLayer.unshift(command); 144 // Covers the case where the tag library already drained the queue: the 145 // unshifted entry would never be read, but a plain call still applies. 146 if (typeof window.gtag === 'function') window.gtag('set', 'campaign', campaign); 147} 148 149// The mirrored SPAs merge location.search into every link they build, so the 150// banner's tags are re-appended on each in-app navigation and one click grows 151// into a chain of tagged pageviews with the tag repeated a dozen times. The 152// links are built during boot, so the tags have to be gone before then. 153function stripUtmFromUrl() { 154 if (!window.history || typeof history.replaceState !== 'function') return; 155 let url; 156 try { url = new URL(window.location.href); } catch (e) { return; } 157 const keys = []; 158 url.searchParams.forEach((value, key) => { 159 if (key.lastIndexOf('utm_', 0) === 0 && keys.indexOf(key) < 0) keys.push(key); 160 }); 161 if (keys.length === 0) return; 162 keys.forEach(key => url.searchParams.delete(key)); 163 try { 164 history.replaceState(history.state, '', url.pathname + url.search + url.hash); 165 } catch (e) { /* replaceState is a no-op on /fb/, where nginx neuters it */ } 166} 167 168// Function to send data to backend with base event data 169function sendEvent(eventType, customData = {}) { 170 const dataToSend = { 171 userID, 172 sessionID, 173 eventType, 174 pagePath: currentPagePath(), 175 pageTitle: document.title ? document.title.substring(0, 512) : null, 176 userAgent: navigator.userAgent, 177 userLanguages: navigator.languages.join(), 178 screenWidth: screen && screen.width ? screen.width : null, 179 screenHeight: screen && screen.height ? screen.height : null, 180 viewportWidth: window.innerWidth || null, 181 viewportHeight: window.innerHeight || null, 182 ...customData 183 }; 184 185 if (pageID !== null) { 186 dataToSend.pageID = pageID; 187 } 188 189 if (document.referrer !== null) { 190 dataToSend.documentReferrer = document.referrer; 191 } 192 193 const xhr = new XMLHttpRequest(); 194 xhr.open('POST', '/analytics', true); 195 xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); 196 xhr.send(JSON.stringify(dataToSend)); 197} 198 199// Send initial page view event. `other` carries the A/B arm, or 'bv:none' where 200// the banner is suppressed; the admin's HD flags look for 'hd'/'hd2' on 201// eventType 'custom', so tagging pageviews here does not collide with them. 202const pageviewData = { other: ['bv:' + (bannerSuppressed || bannerDisabled ? 'none' : bannerVariant)] }; 203if (redirectedTo) pageviewData.redirected_to = redirectedTo; 204sendEvent('pageview', pageviewData); 205if (redirectedTo) { 206 document.cookie = 'smm_redirected_to=; Max-Age=0; path=/'; 207} 208 209// The pageview above has already serialised the tagged URL, so our own stats 210// keep the campaign; from here on the tags live in arrivalUtm instead.
211passUtmToGoogleTag(); 212stripUtmFromUrl(); 213 214// Check the path before attaching the event listener 215if (window.location.pathname.startsWith('/fb/') || window.location.pathname.startsWith('/wiki/')) { 216 document.addEventListener('DOMContentLoaded', (event) => { 217 document.body.addEventListener('click', function(event) { 218 let target = event.target; // start with the target element 219 220 // climb up the DOM tree 221 while (target != null) { 222 if (target.id === 'download') { 223 // found the element we're interested in, handle the event 224 sendEvent('custom', { click: 'download' }); 225 return; 226 } 227 target = target.parentElement; 228 } 229 }); 230 }); 231} 232 233if (window.location.pathname.startsWith('/news/')) { 234 document.addEventListener('DOMContentLoaded', (event) => { 235 document.body.addEventListener('click', function(event) { 236 let target = event.target; 237 while (target != null) { 238 if (target.getAttribute('href') && target.getAttribute('href').startsWith('/news/')) { 239 sendEvent('custom', { click: 'news' }); 240 return; 241 } 242 target = target.parentElement; 243 } 244 }); 245 }); 246} 247 248if (window.location.pathname.startsWith('/duckduckgo/')) { 249 const originalFetch = window.fetch; 250 window.fetch = function(req, options) { 251 try { 252 let url = (typeof req === 'string') ? req : req.url; 253 if(url == '/duckduckgo/duckchat/v1/chat') { 254 sendEvent('custom', { click: 'v1chat' }); 255 } 256 return originalFetch(req, options); 257 } catch (e) { 258 console.error(e); 259 } 260 }; 261} 262 263// Register the service worker 264if ('serviceWorker' in navigator) { 265 navigator.serviceWorker.register('/service-worker.js', { scope: '/' }) 266 .then(function(registration) { 267 console.log('Service Worker registered with scope:', registration.scope); 268 }) 269 .catch(function(error) { 270 console.log('Service Worker registration failed:', error); 271 }); 272 273 navigator.serviceWorker.addEventListener('message', event => { 274 if (event.data && event.data.type === 'REDIRECT') { 275 window.location = event.data.url; 276 } 277 }); 278} 279 280let mm = []; 281let hd = false; 282 283document.addEventListener('mousemove', function(event) { 284 if (hd) return; 285 286 const x = event.clientX; 287 const y = event.clientY; 288 const timestamp = Date.now(); 289 290 mm.push({ x, y, timestamp }); 291 292 if (mm.length >= 50) { 293 if (isHL(mm)) { 294 hd = true; 295 setCookie('smm_hd', '1', 3650); // persistent human-likelihood signal (survey gating) 296 notifyHd(); 297 sendEvent('custom', { other: ['hd'] }); 298 } 299 mm = []; 300 } 301}); 302 303function isHL(movements) { 304 let totalDistance = 0; 305 let totalTime = 0; 306 307 for (let i = 1; i < movements.length; i++) { 308 const dx = movements[i].x - movements[i - 1].x; 309 const dy = movements[i].y - movements[i - 1].y; 310 const dt = movements[i].timestamp - movements[i - 1].timestamp; 311 312 totalDistance += Math.sqrt(dx * dx + dy * dy); 313 totalTime += dt; 314 } 315 316 const averageSpeed = totalDistance / totalTime; 317 return averageSpeed > 0.1 && averageSpeed < 1.0; 318} 319 320// Pointer-event based human-likelihood signal (mobile-friendly) 321let pm = []; 322let hd2 = false; 323const pointerTypeCounts = {}; 324 325function trackPointerType(pointerType) { 326 const type = pointerType || 'unknown'; 327 pointerTypeCounts[type] = (pointerTypeCounts[type] || 0) + 1; 328} 329 330function sendHd2Event() { 331 if (hd2) return; 332 hd2 = true; 333 setCookie('smm_hd', '1', 3650); // persistent human-likelihood signal (survey gating) 334 notifyHd(); 335 sendEvent('custom', { other: ['hd2'], pointerTypes: Object.keys(pointerTypeCounts).sort() }); 336} 337 338// Let the banner survey react to a freshly-set HD signal without a page reload. 339function notifyHd() { 340 try { 341 document.dispatchEvent(new CustomEvent('smm:hd')); 342 } catch (e) { /* CustomEvent unsupported: survey will still show on next load */ } 343} 344 345function isHL2(movements) { 346 if (movements.length < 12) return false; 347 348 let totalDistance = 0; 349 let totalTime = 0; 350 let stationarySteps = 0; 351 let directionChanges = 0; 352 let prevDirection = null; 353 354 for (let i = 1; i < movements.length; i++) { 355 const dx = movements[i].x - movements[i - 1].x; 356 const dy = movements[i].y - movements[i - 1].y; 357 const dt = movements[i].timestamp - movements[i - 1].timestamp; 358 const stepDistance = Math.sqrt(dx * dx + dy * dy); 359 360 totalDistance += stepDistance; 361 totalTime += dt; 362 363 if (stepDistance < 0.5) { 364 stationarySteps++; 365 continue; 366 } 367 368 const direction = Math.atan2(dy, dx); 369 if (prevDirection !== null) { 370 const directionDelta = Math.abs(direction - prevDirection); 371 if (directionDelta > 0.3 && directionDelta < 3.0) directionChanges++; 372 } 373 prevDirection = direction; 374 } 375 376 if (totalTime <= 0) return false;
377 378 const averageSpeed = totalDistance / totalTime; 379 const stationaryRatio = stationarySteps / Math.max(movements.length - 1, 1); 380 381 // Wide bounds to support mouse and touch, while rejecting ultra-linear or fully static traces. 382 return ( 383 totalDistance > 80 && 384 totalTime > 150 && 385 averageSpeed > 0.03 && 386 averageSpeed < 3.0 && 387 stationaryRatio < 0.9 && 388 directionChanges >= 2 389 ); 390} 391 392document.addEventListener('pointerdown', function(event) { 393 if (hd2 || !event.isPrimary) return; 394 trackPointerType(event.pointerType); 395 pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() }); 396 if (pm.length > 80) pm.shift(); 397}); 398 399document.addEventListener('pointermove', function(event) { 400 if (hd2 || !event.isPrimary) return; 401 trackPointerType(event.pointerType); 402 pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() }); 403 if (pm.length > 80) pm.shift(); 404 405 if (pm.length >= 20 && isHL2(pm)) { 406 sendHd2Event(); 407 } 408}); 409 410document.addEventListener('pointerup', function(event) { 411 if (hd2 || !event.isPrimary) return; 412 trackPointerType(event.pointerType); 413 pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() }); 414 if (pm.length > 80) pm.shift(); 415 416 if (isHL2(pm)) { 417 sendHd2Event(); 418 } 419}); 420 421// ââ Banner ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 422 423(function () { 424 const BANNER_ID = '__smart-mirrors-banner__';
425 const NOTICE_ID = '__smart-mirrors-notice__'; 426 const NOTICE_CHIP_ID = '__smart-mirrors-notice-chip__'; 427 if (document.getElementById(BANNER_ID) || document.getElementById(NOTICE_ID)) return; 428 429 // Capture the script element synchronously â document.currentScript is only 430 // valid during initial script execution, so we grab it here at the top of 431 // the IIFE rather than inside a deferred callback. 432 const scriptEl = document.currentScript || (function () { 433 const scripts = document.getElementsByTagName('script'); 434 return scripts[scripts.length - 1]; 435 }()); 436 437 const origin = (scriptEl && scriptEl.getAttribute('data-origin')) || (function () { 438 const m = ((scriptEl && scriptEl.getAttribute('src')) || '').match(/[?&]origin=([^&]*)/); 439 return m ? decodeURIComponent(m[1]) : null; 440 }()); 441 442 const position = (scriptEl && scriptEl.getAttribute('data-position')) === 'bottom' ? 'bottom' : 'top'; 443 const isBottom = position === 'bottom'; 444 445 // ââ Language detection ââââââââââââââââââââââââââââââââââââââââââââââââââ 446 447 function langFromPath(path) { 448 for (const code of ['zh', 'ru', 'fa']) { 449 if (new RegExp('/' + code + '(?:/|-|$)').test(path)) return code; 450 } 451 return null; 452 } 453 454 function langFromBrowser() { 455 const langs = Array.from(navigator.languages || (navigator.language ? [navigator.language] : [])); 456 for (const l of langs) { 457 const lc = l.toLowerCase(); 458 if (lc === 'zh' || lc.startsWith('zh-')) return 'zh'; 459 if (lc === 'ru' || lc.startsWith('ru-')) return 'ru'; 460 if (lc === 'fa' || lc.startsWith('fa-')) return 'fa'; 461 } 462 return null; 463 } 464 465 const lang = (window.location.pathname.startsWith('/wikipedia') || window.location.pathname.startsWith('/fzh/')) 466 ? 'zh' 467 : (langFromPath(window.location.pathname) || langFromBrowser() || 'en'); 468 469 // Strip the first non-empty path segment so the origin link lands on the 470 // equivalent page: /wikipedia/Title -> /Title, /fb/en -> /en, / -> '' 471 function originPath() { 472 const parts = window.location.pathname.split('/').filter(p => p !== ''); 473 if (parts.length <= 1) return ''; 474 return '/' + parts.slice(1).join('/'); 475 } 476 477 // Language-suffixed fb/vpn paths (zh and ru only); vpn gets a trailing slash 478 const fbLangSuffix = { zh: '/zh', ru: '/ru' }; 479 const vpnLangSuffix = { zh: '/zh/', ru: '/ru/' }; 480 481 // Campaign tags for the fb/vpn links. Both are same-origin paths nginx proxies 482 // to freebrowser.org / greatfirevpn.com, so without these the destination's GA 483 // reports banner clicks as direct traffic. The mirror host is deliberately not 484 // in utm_source: GA already has it as the Hostname dimension. Our own stats pick 485 // the params up too, via the query string in sendEvent's pagePath. 486 // utm_content doubles as the A/B label so the admin's Banner Clicks panel, 487 // which groups by campaign + content, separates the arms with no query 488 // changes: "zh-card-top" vs "zh-sheet-top". It names the arm rather than the 489 // shape actually drawn, because the sheet arm falls back to the card on 490 // desktop and those clicks still belong to the sheet's denominator â the 491 // event's viewportWidth says which shape was on screen. Position is the 492 // mirror's configured one even in the sheet arm, which overrides it on 493 // phones: that is what makes it possible to ask whether forcing bottom helps 494 // more on the twelve mirrors configured top than on the two configured bottom. 495 function withUtm(path) { 496 const params = new URLSearchParams(); 497 params.set('utm_source', 'smart-mirrors'); 498 params.set('utm_medium', 'banner'); 499 if (pageID) params.set('utm_campaign', pageID); 500 params.set('utm_content', lang + '-' + bannerVariant + '-' + position); 501 return path + (path.indexOf('?') === -1 ? '?' : '&') + params.toString(); 502 } 503 504 const fbPath = withUtm('/fb' + (fbLangSuffix[lang] || '')); 505 const vpnPath = withUtm('/vpn' + (vpnLangSuffix[lang] || '')); 506 507 // ââ Translations ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 508 // With origin: s0 + originLink + s1 + gfLink + s2 + fbLink + s3 + vpnLink + s4 + emailLink + s5 509 // Without origin: g0 + gfLink + g1 + fbLink + g2 + vpnLink + g3 + emailLink + g4 510 511 const T = { 512 en: { 513 s0: 'This is a mirror of ', s1: '. It is operated by ', 514 s2: ' to make it easier to access. Since the mirror may be blocked we recommend that you download ', 515 s3: ' or ', s4: ' for long term access. If you have questions you can email us ', s5: '.', 516 g0: 'This website is a mirror operated by ', 517 g1: ' to make it easier to access. Since the mirror may be blocked we recommend that you download ', 518 g2: ' or ', g3: ' for long term access. If you have questions you can email us ', g4: '.', 519 gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN', 520 }, 521 zh: { 522 s0: 'è¿æ¯', s1: 'çéåç½ç«ï¼ç±', 523 s2: 'è¿è¥ï¼æ¨å¨å¸®å©æ¨æ´ä¾¿æ·å°è®¿é®ãç±äºéåå¯è½è¢«å±è½ï¼æä»¬å»ºè®®æ¨ä¸è½½',
524 s3: 'æ', s4: '以è·å¾é¿æè®¿é®ã妿çé®ï¼è¯·åéé®ä»¶è³', s5: 'ã', 525 g0: 'æ¬ç½ç«æ¯ç±', 526 g1: 'è¿è¥çéåç½ç«ï¼æ¨å¨å¸®å©æ¨æ´ä¾¿æ·å°è®¿é®ãç±äºéåå¯è½è¢«å±è½ï¼æä»¬å»ºè®®æ¨ä¸è½½', 527 g2: 'æ', g3: '以è·å¾é¿æè®¿é®ã妿çé®ï¼è¯·åéé®ä»¶è³', g4: 'ã', 528 gf: 'GreatFire.org', fb: 'èªç±æµè§', vpn: 'GreatFireVPN', 529 }, 530 ru: { 531 s0: 'ÐÑо зеÑкало ÑайÑа ', s1: '. Ðно поддеÑживаеÑÑÑ Ð¾ÑганизаÑией ', 532 s2: ', ÑÑÐ¾Ð±Ñ Ð¾Ð±Ð»ÐµÐ³ÑиÑÑ Ð´Ð¾ÑÑÑп к ÑеÑÑÑÑÑ. ÐоÑколÑÐºÑ Ð·ÐµÑкало Ð¼Ð¾Ð¶ÐµÑ Ð±ÑÑÑ Ð·Ð°Ð±Ð»Ð¾ÐºÐ¸Ñовано, Ð¼Ñ ÑекомендÑем ÑкаÑаÑÑ ', 533 s3: ' или ', s4: ' Ð´Ð»Ñ Ð´Ð¾Ð»Ð³Ð¾ÑÑоÑного доÑÑÑпа. Ðо вопÑоÑам пиÑиÑе нам на ', s5: '.', 534 g0: 'ÐÑÐ¾Ñ ÑÐ°Ð¹Ñ ÑвлÑеÑÑÑ Ð·ÐµÑкалом, поддеÑживаемÑм оÑганизаÑией ', 535 g1: ', ÑÑÐ¾Ð±Ñ Ð¾Ð±Ð»ÐµÐ³ÑиÑÑ Ð´Ð¾ÑÑÑп. ÐоÑколÑÐºÑ Ð·ÐµÑкало Ð¼Ð¾Ð¶ÐµÑ Ð±ÑÑÑ Ð·Ð°Ð±Ð»Ð¾ÐºÐ¸Ñовано, Ð¼Ñ ÑекомендÑем ÑкаÑаÑÑ ', 536 g2: ' или ', g3: ' Ð´Ð»Ñ Ð´Ð¾Ð»Ð³Ð¾ÑÑоÑного доÑÑÑпа. Ðо вопÑоÑам пиÑиÑе нам на ', g4: '.', 537 gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN', 538 }, 539 fa: {
540 s0: 'اÛÙ ÛÚ© Ø¢ÛÙ٠از ', s1: ' است Ú©Ù ØªÙØ³Ø· ', 541 s2: ' ادار٠٠ÛâØ´ÙØ¯ تا Ø¯Ø³ØªØ±Ø³Û Ø±Ø§ آساÙâØªØ± Ú©ÙØ¯. از Ø¢ÙØ¬Ø§ ک٠اÛ٠آÛÙ٠٠٠ک٠است Ù Ø³Ø¯ÙØ¯ Ø´ÙØ¯Ø ØªÙØµÛÙ Ù ÛâÚ©ÙÛÙ ', 542 s3: ' ÛØ§ ', s4: ' را Ø¨Ø±Ø§Û Ø¯Ø³ØªØ±Ø³Û Ø¨ÙÙØ¯Ù دت داÙÙÙØ¯ Ú©ÙÛØ¯. در ØµÙØ±Øª Ø¯Ø§Ø´ØªÙ Ø³ÙØ§Ù با ٠ا از طرÛ٠اÛÙ Û٠ت٠اس Ø¨Ú¯ÛØ±Ûد ', s5: '.', 543 g0: 'اÛÙ ÙØ¨âØ³Ø§ÛØª ÛÚ© Ø¢ÛÙ٠است Ú©Ù ØªÙØ³Ø· ', 544 g1: ' ادار٠٠ÛâØ´ÙØ¯ تا Ø¯Ø³ØªØ±Ø³Û Ø±Ø§ آساÙâØªØ± Ú©ÙØ¯. از Ø¢ÙØ¬Ø§ ک٠اÛ٠آÛÙ٠٠٠ک٠است Ù Ø³Ø¯ÙØ¯ Ø´ÙØ¯Ø ØªÙØµÛÙ Ù ÛâÚ©ÙÛÙ ', 545 g2: ' ÛØ§ ', g3: ' را Ø¨Ø±Ø§Û Ø¯Ø³ØªØ±Ø³Û Ø¨ÙÙØ¯Ù دت داÙÙÙØ¯ Ú©ÙÛØ¯. در ØµÙØ±Øª Ø¯Ø§Ø´ØªÙ Ø³ÙØ§Ù با ٠ا از طرÛ٠اÛÙ Û٠ت٠اس Ø¨Ú¯ÛØ±Ûد ', g4: '.', 546 gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN', 547 }, 548 }; 549 550 const tr = T[lang] || T.en; 551 552 // ââ Survey (HD-gated, per browser language) âââââââââââââââââââââââââââââ 553 // Hard-coded per-language surveys. Each language is its own survey (own id), so 554 // content can diverge later (e.g. a question added in one language only). 555 // Stored one document per answered question via POST /analytics 556 // (eventType:'survey'); see survey-feature-design.md §8. 557 558 // Master switch. False keeps the whole implementation in place but stops the 559 // survey ever rendering: surveyApplicable() gates both the auto-popup and the 560 // reopen link, which are the only two ways it can reach the screen. 561 const SURVEYS_ENABLED = false; 562 563 const SURVEYS = { 564 zh: { 565 id: 'civic-efficacy-v1', 566 scaleId: 'agreement-1to6', 567 showDelayMs: 4000, // wait a few seconds before auto-showing, to grab attention 568 ui: { 569 title: 'é®å·è°æ¥', 570 // Merged invite + progress line (shown under the title, centered). 571 subtitle: function (n, total) { 572 return 'æä»¬æ³å¬å¬æ¨ççæ³ï¼å¿åï¼Â· é®é¢ ' + n + ' / ' + total; 573 }, 574 thanks: 'æè°¢æ¨çåä¸ï¼', 575 reopen: 'åä¸é®å·è°æ¥ âº', 576 close: 'å ³é', 577 }, 578 questions: [ 579 { id: 'Q1', text: 'åæè¿æ ·ç人è½å¤å½±å社ä¼çè¿ä½æ¹å¼ã' }, 580 { id: 'Q2', text: 'å½ååºéè¦å³å®æ¶ï¼åæè¿æ ·ç人çæè§ä¼è¢«å¬åã' }, 581 { id: 'Q3', text: 'å³ä½¿æ¹åç¼æ ¢ï¼å°±å ¬å ±é®é¢å声乿¯å¼å¾çã' }, 582 { id: 'Q4', text: 'æ®é人åºè¯¥å¨å½±åèªèº«çæ´»çå³å®ä¸æ¥ææ´å¤çåè¨æã' }, 583 ], 584 options: [ 585 { value: 1, label: 'é常ä¸åæ' }, 586 { value: 2, label: 'ä¸åæ' }, 587 { value: 3, label: 'æ¢ä¸åæä¹ä¸å对' }, 588 { value: 4, label: 'åæ' }, 589 { value: 5, label: 'é常åæ' }, 590 { value: 6, label: '䏿¿åç' }, 591 ], 592 }, 593 ru: { 594 id: 'civic-efficacy-ru-v1', 595 scaleId: 'agreement-1to6', 596 showDelayMs: 4000, 597 ui: { 598 title: 'ÐпÑоÑ', 599 subtitle: function (n, total) { 600 return 'Ðам инÑеÑеÑно ваÑе мнение (анонимно) · ÐопÑÐ¾Ñ ' + n + ' / ' + total; 601 }, 602 thanks: 'СпаÑибо за ÑÑаÑÑие!', 603 reopen: 'ÐÑойÑи опÑÐ¾Ñ âº', 604 close: 'ÐакÑÑÑÑ', 605 }, 606 questions: [ 607 { id: 'Q1', text: 'Такие лÑди, как Ñ, могÑÑ Ð²Ð»Ð¸ÑÑÑ Ð½Ð° Ñо, как ÑÑÑÑоено обÑеÑÑво.' }, 608 { id: 'Q2', text: 'Ðогда пÑинимаÑÑÑÑ Ð²Ð°Ð¶Ð½Ñе ÑеÑениÑ, к Ñаким лÑдÑм, как Ñ, пÑиÑлÑÑиваÑÑÑÑ.' }, 609 { id: 'Q3', text: 'ÐÑÑказÑваÑÑÑÑ Ð¿Ð¾ обÑеÑÑвеннÑм вопÑоÑам ÑÑоиÑ, даже еÑли пеÑÐµÐ¼ÐµÐ½Ñ Ð¸Ð´ÑÑ Ð¼ÐµÐ´Ð»ÐµÐ½Ð½Ð¾.' }, 610 { id: 'Q4', text: 'ÐбÑÑнÑе лÑди Ð´Ð¾Ð»Ð¶Ð½Ñ Ð¸Ð¼ÐµÑÑ Ð±Ð¾Ð»ÑÑе влиÑÐ½Ð¸Ñ Ð½Ð° ÑеÑениÑ, коÑоÑÑе заÑÑагиваÑÑ Ð¸Ñ Ð¶Ð¸Ð·Ð½Ñ.' }, 611 ], 612 options: [ 613 { value: 1, label: 'ÐолноÑÑÑÑ Ð½Ðµ ÑоглаÑен(на)' }, 614 { value: 2, label: 'Ðе ÑоглаÑен(на)' }, 615 { value: 3, label: 'ÐейÑÑалÑно' }, 616 { value: 4, label: 'СоглаÑен(на)' }, 617 { value: 5, label: 'ÐолноÑÑÑÑ ÑоглаÑен(на)' }, 618 { value: 6, label: 'ÐÑедпоÑиÑÐ°Ñ Ð½Ðµ оÑвеÑаÑÑ' }, 619 ], 620 }, 621 }; 622 623 // Pick the survey for the user's *browser* language (never the path-forced 624 // `lang`, which is pinned to 'zh' on /wikipedia and /fzh/). null = no survey 625 // for this language. 626 const SURVEY = SURVEYS[langFromBrowser()] || null; 627 628 const surveyDoneCookie = SURVEY ? 'smm_survey_' + SURVEY.id : null; 629 630 function surveyState() { 631 return surveyDoneCookie ? getCookie(surveyDoneCookie) : ''; // '' | 'dismissed' | 'done' 632 } 633 634 // Applicable = surveys are switched on, one exists for this browser language, 635 // and the persistent HD signal is set. See survey-feature-design.md §8. 636 function surveyApplicable() { 637 return SURVEYS_ENABLED && !!SURVEY && getCookie('smm_hd') === '1'; 638 } 639 640 // Auto-eligible = should pop up on its own (applicable, and not already 641 // completed or dismissed). 642 function surveyAutoEligible() { 643 const s = surveyState(); 644 return surveyApplicable() && s !== 'done' && s !== 'dismissed'; 645 } 646 647 // ââ DOM helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 648 649 function lnk(href, label) { 650 const a = document.createElement('a'); 651 a.href = href; 652 a.textContent = label; 653 a.target = '_blank'; 654 a.rel = 'noopener noreferrer'; 655 a.style.cssText = 'color:inherit;text-decoration:none;font-weight:bold'; 656 a.onmouseover = () => { a.style.textDecoration = 'underline'; }; 657 a.onmouseout = () =>
657 { a.style.textDecoration = 'none'; }; 658 return a; 659 } 660 661 // ââ Build banner âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 662 663 // On narrow screens the long mirror-info message dominates the viewport, so we 664 // collapse it to a couple of lines and let a tap on the banner expand it. 665 const isMobile = !!(window.matchMedia && window.matchMedia('(max-width: 600px)').matches); 666 667 // The sheet is a phone-only treatment: desktop keeps the card untouched, 668 // both because a bottom sheet is a phone idiom and because round 2 showed 669 // desktop's rate is an artefact of automated traffic rather than a design 670 // problem worth spending an arm on. Desktop visitors in the sheet arm 671 // therefore see exactly what the card arm sees, which makes desktop a running 672 // A/A check on the split. 673 const showSheet = bannerVariant === 'sheet' && isMobile; 674 675 const banner = document.createElement('div'); 676 banner.id = BANNER_ID; 677 banner.style.cssText = [ 678 'position:fixed', isBottom ? 'bottom:0' : 'top:0', 'left:0', 'width:100%', 679 'z-index:2147483647', 'box-sizing:border-box', 680 'background:#1a1a2e', 'color:#e0e0ff', 681 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', 682 'font-size:' + (isMobile ? '12px' : '13px'), 683 'line-height:' + (isMobile ? '1.4' : '1.5'), 684 'padding:' + (isMobile ? '6px 12px' : '8px 16px'), 685 'display:flex', 'flex-direction:column', 'gap:8px', 686 isBottom ? 'box-shadow:0 -2px 8px rgba(0,0,0,0.4)' : 'box-shadow:0 2px 8px rgba(0,0,0,0.4)', 687 lang === 'fa' ? 'direction:rtl' : 'direction:ltr', 688 ].join(';'); 689 690 // Top row: existing mirror-info message + dismiss button. 691 const topRow = document.createElement('div'); 692 topRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%'; 693 694 const msg = document.createElement('span'); 695 // Desktop: centered. Mobile: left-aligned and collapsed (managed by applyMsgCollapse). 696 msg.style.cssText = isMobile ? 'flex:1;min-width:0;text-align:left' : 'flex:1;min-width:0;text-align:center'; 697 698 // Shared by both arms: the bar shows it as its whole content, the card folds 699 // it behind "about this mirror". Builds fresh nodes on each call. 700 function fillMirrorMessage(el) { 701 const emailLink = lnk('mailto:[email protected]', '[email protected]'); 702 if (origin) { 703 el.append( 704 tr.s0, lnk('https://' + origin + originPath(), origin), tr.s1, 705 lnk('https://greatfire.org', tr.gf), tr.s2, 706 lnk(fbPath, tr.fb), tr.s3, lnk(vpnPath, tr.vpn), 707 tr.s4, emailLink, tr.s5, 708 ); 709 } else { 710 el.append( 711 tr.g0, lnk('https://greatfire.org', tr.gf), tr.g1, 712 lnk(fbPath, tr.fb), tr.g2, lnk(vpnPath, tr.vpn), 713 tr.g3, emailLink, tr.g4, 714 ); 715 } 716 } 717 718 fillMirrorMessage(msg); 719 720 const closeBtn = document.createElement('button'); 721 closeBtn.textContent = '\u00D7'; 722 closeBtn.setAttribute('aria-label', 'Dismiss'); 723 closeBtn.style.cssText = 'background:none;border:none;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:0 4px;flex-shrink:0;opacity:0.7'; 724 closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; }; 725 closeBtn.onmouseout = () => { closeBtn.style.opacity = '0.7'; }; 726 closeBtn.onclick = () => { 727 banner.style.display = 'none'; 728 document.body.style[padProp] = originalPad ? originalPad + 'px' : ''; 729 }; 730 731 // Discreet link that (re)opens the survey. Shown whenever the survey is 732 // applicable but not currently open (including after it was dismissed), so a 733 // user who closed it can still bring it back. 734 const reopenLink = document.createElement('button'); 735 reopenLink.type = 'button'; 736 reopenLink.textContent = SURVEY ? SURVEY.ui.reopen : ''; 737 reopenLink.style.cssText = [ 738 'display:none', 'background:none', 'border:none', 'color:#aeb4ff', 739 'font:inherit', 'font-size:12px', 'cursor:pointer', 'text-decoration:underline', 740 'padding:0 4px', 'flex-shrink:0', 'white-space:nowrap', 741 ].join(';'); 742 reopenLink.onmouseover = () => { reopenLink.style.color = '#ffffff'; }; 743 reopenLink.onmouseout = () => { reopenLink.style.color = '#aeb4ff'; }; 744 reopenLink.onclick = () => { openSurvey(); }; 745 746 // Mobile: a caret hints the message is expandable (tap anywhere on the banner 747 // toggles it). It's a <span>, not a <button>, so the banner tap handler still 748 // sees it (buttons/links are excluded from the toggle). 749 let caret = null; 750 if (isMobile) { 751 caret = document.createElement('span'); 752 caret.setAttribute('aria-hidden', 'true'); 753 caret.style.cssText = 'flex-shrink:0;
753opacity:0.7;font-size:12px;padding:0 2px'; 754 } 755 756 // Mobile puts the reopen link on its own centered line; desktop keeps it inline. 757 if (isMobile) { 758 topRow.append(msg, caret, closeBtn); 759 banner.append(topRow); 760 const reopenRow = document.createElement('div'); 761 reopenRow.style.cssText = 'width:100%;text-align:center'; 762 reopenRow.append(reopenLink); 763 banner.append(reopenRow); 764 } else { 765 topRow.append(msg, reopenLink, closeBtn); 766 banner.append(topRow); 767 } 768 769 // ââ Collapsible message (mobile) ââââââââââââââââââââââââââââââââââââââââââ 770 const MSG_CLAMP = 'display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;'; 771 let msgCollapsed = isMobile; 772 function applyMsgCollapse() { 773 if (!isMobile) return; 774 msg.style.cssText = 'flex:1;min-width:0;text-align:left;' + (msgCollapsed ? MSG_CLAMP : ''); 775 if (caret) caret.textContent = msgCollapsed ? '\u25BE' : '\u25B4'; // â¾ / â´ 776 } 777 if (isMobile) { 778 banner.addEventListener('click', (e) => { 779 if (surveyOpen) return; // survey has its own controls 780 if (e.target.closest('a, button')) return; // don't hijack links / Ã / reopen 781 msgCollapsed = !msgCollapsed; 782 applyMsgCollapse(); 783 applyPad(); 784 }); 785 } 786 787 // ââ Padding management ââââââââââââââââââââââââââââââââââââââââââââââââââââ
788 // The banner is fixed, so reserve body space equal to its height. The survey 789 // grows the banner as it renders, so recompute on demand rather than once. 790 const padProp = isBottom ? 'paddingBottom' : 'paddingTop'; 791 // Computed in insert(), once document.body is guaranteed to exist (the script 792 // may run in <head>, where document.body is still null). 793 let originalPad = 0; 794 function applyPad() { 795 document.body.style[padProp] = (originalPad + banner.offsetHeight) + 'px'; 796 } 797 798 // ââ Survey ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 799 // The survey replaces the banner's main content while open (topRow hidden), 800 // with its own close button. State is tracked across reopen so we resume where 801 // the user left off rather than re-asking answered questions. 802 803 let surveyPanel = null; 804 let surveyOpen = false; 805 let surveyScheduledOrShown = false; 806 let surveyIdx = 0; 807 808 function refreshReopenLink() { 809 const show = !surveyOpen && surveyApplicable() && surveyState() !== 'done'; 810 reopenLink.style.display = show ? 'inline' : 'none'; 811 } 812 813 function optionButton(q, opt) { 814 const b = document.createElement('button'); 815 b.type = 'button'; 816 b.textContent = opt.label; 817 b.style.cssText = [ 818 'background:#eceeff', 'color:#1a1a2e', 819 'border:1px solid #b9bdf2', 'border-radius:16px', 820 'padding:6px 14px', 'font-size:13px', 'font-weight:600', 821 'cursor:pointer', 'line-height:1.3', 'box-shadow:0 1px 2px rgba(0,0,0,0.3)', 822 ].join(';'); 823 b.onmouseover = () => { b.style.background = '#ffffff'; b.style.borderColor = '#6b71e8'; }; 824 b.onmouseout = () => { b.style.background = '#eceeff'; b.style.borderColor = '#b9bdf2'; }; 825 b.onclick = () => answer(q, opt); 826 return b; 827 } 828 829 function buildSurveyPanel() { 830 const panel = document.createElement('div'); 831 panel.style.cssText = [ 832 'width:100%', 'box-sizing:border-box', 833 'display:flex', 'flex-direction:column', 'gap:10px', 'align-items:center', 834 'text-align:center', 835 ].join(';'); 836 837 // Header: centered title; survey-only close pinned to the top corner so it 838 // doesn't push the title off-center. 839 const header = document.createElement('div'); 840 header.style.cssText = 'position:relative;width:100%;display:flex;align-items:center;justify-content:center'; 841 842 const title = document.createElement('div'); 843 title.textContent = SURVEY.ui.title; 844 title.style.cssText = 'font-size:13px;font-weight:bold;text-align:center'; 845 846 const sClose = document.createElement('button'); 847 sClose.type = 'button'; 848 sClose.textContent = '\u00D7'; 849 sClose.setAttribute('aria-label', SURVEY.ui.close); 850 sClose.title = SURVEY.ui.close; 851 sClose.style.cssText = 'position:absolute;top:50%;transform:translateY(-50%);' + 852 (lang === 'fa' ? 'left:0;' : 'right:0;') + 853 'background:none;border:none;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:0 4px;opacity:0.7'; 854 sClose.onmouseover = () => { sClose.style.opacity = '1'; }; 855 sClose.onmouseout = () => { sClose.style.opacity = '0.7'; }; 856 sClose.onclick = () => closeSurvey('dismiss'); 857 header.append(title, sClose); 858 859 const body = document.createElement('div'); 860 body.style.cssText = 'width:100%;display:flex;flex-direction:column;gap:8px;align-items:center;text-align:center;transition:opacity 0.16s ease'; 861 862 panel.append(header, body); 863 panel._body = body; 864 return panel; 865 } 866 867 // Swap the survey body content with a short cross-fade. 868 function swapBody(buildFn) { 869 const body = surveyPanel._body; 870 body.style.opacity = '0'; 871 setTimeout(() => { 872 body.textContent = ''; 873 buildFn(body); 874 applyPad(); 875 requestAnimationFrame(() => { body.style.opacity = '1'; }); 876 }, 160); 877 } 878 879 function showQuestion() { 880 swapBody((body) => { 881 const sub = document.createElement('div'); 882 sub.textContent = SURVEY.ui.subtitle(surveyIdx + 1, SURVEY.questions.length); 883 sub.style.cssText = 'font-size:12px;opacity:0.75'; 884 885 const q = SURVEY.questions[surveyIdx]; 886 const qText = document.createElement('div'); 887 qText.textContent = q.text; 888 qText.style.cssText = 'font-size:16px;font-weight:bold;line-height:1.4;max-width:680px'; 889 890 const opts = document.createElement('div'); 891 opts.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center'; 892 SURVEY.options.forEach((opt) => opts.append(optionButton(q, opt))); 893 894 body.append(sub, qText, opts); 895 }); 896 } 897 898 function answer(q, opt) { 899 sendEvent('survey', { 900 surveyId: SURVEY.id, 901 questionId: q.id, 902 questionText: q.text, 903 scaleId: SURVEY.scaleId, 904 answerValue: opt.value, 905 answerLabel: opt.label, 906 lang: langFromBrowser(), 907 hadHd: true, 908 }); 909 surveyIdx++; 910 if (surveyIdx < SURVEY.questions.length) { 911 showQuestion(); 912 } else { 913 finishSurvey(); 914 } 915 } 916 917 function finishSurvey() {
918 setCookie(surveyDoneCookie, 'done', 3650); 919 swapBody((body) => { 920 const thanks = document.createElement('div'); 921 thanks.textContent = SURVEY.ui.thanks; 922 thanks.style.cssText = 'font-size:16px;font-weight:bold'; 923 body.append(thanks); 924 }); 925 setTimeout(() => closeSurvey('done'), 2660); 926 } 927 928 function openSurvey() { 929 if (surveyOpen) return; 930 surveyScheduledOrShown = true; 931 if (!surveyPanel) { 932 surveyPanel = buildSurveyPanel(); 933 banner.append(surveyPanel); 934 } 935 surveyPanel.style.display = 'flex'; 936 surveyOpen = true; 937 topRow.style.display = 'none'; // survey covers the main banner content 938 if (surveyIdx >= SURVEY.questions.length) surveyIdx = 0; 939 applyPad(); 940 showQuestion(); 941 } 942 943 function closeSurvey(reason) { 944 if (reason === 'dismiss') setCookie(surveyDoneCookie, 'dismissed', 3650); 945 surveyOpen = false; 946 if (surveyPanel) surveyPanel.style.display = 'none'; 947 topRow.style.display = 'flex'; 948 refreshReopenLink(); 949 applyPad(); 950 } 951 952 function evaluateSurvey() { 953 if (surveyOpen || surveyScheduledOrShown) { refreshReopenLink(); return; } 954 if (surveyAutoEligible()) { 955 surveyScheduledOrShown = true; 956 setTimeout(() => { 957 const s = surveyState(); 958 if (!surveyOpen && s !== 'done' && s !== 'dismissed') openSurvey(); 959 }, SURVEY.showDelayMs); 960 } else { 961 refreshReopenLink(); 962 } 963 } 964 965 // ââ Card variant ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 966 // One hook, three benefits, one button. The mirror explanation is still there 967 // but folded behind "about this mirror" instead of being the entire banner. 968 // Floats over the page, so unlike the bar it needs no body padding. 969 970 // The about text is deliberately shorter than the bar's and names no product: 971 // the card already has one CTA, and a second download suggestion inside the 972 // fold would compete with it. 973 // With origin: a0 + originLink + a1 + gfLink + a2 + emailLink 974 // Without origin: g0 + gfLink + g1 + emailLink 975 const CARD_T = { 976 en: { 977 title: 'Tired of blocked sites?', 978 b: ['Unblocks everything', 'Free for 30 days', 'No signup needed'], 979 cta: 'Get GreatFireVPN', about: 'about this mirror', 980 a0: 'Mirror of ', a1: ', operated by ', a2: ' to make it easier to access. Questions: ', 981 g0: 'Mirror operated by ', g1: ' to make this site easier to access. Questions: ', 982 }, 983 zh: { 984 title: 'åå¤äºè¢«å±è½çç½ç«ï¼', 985 b: ['è§£éææç½ç«', 'å è´¹è¯ç¨30天', 'æ éæ³¨å'], 986 cta: 'è·å GreatFireVPN', about: 'å ³äºæ¬éå', 987 a0: 'æ¬ç«æ¯', a1: 'çéåï¼ç±', a2: 'è¿è¥ï¼æ¹ä¾¿è®¿é®ã妿çé®ï¼', 988 g0: 'æ¬ç«æ¯ç±', g1: 'è¿è¥çéåï¼æ¹ä¾¿è®¿é®ã妿çé®ï¼', 989 }, 990 ru: { 991 title: 'Ðадоели заблокиÑованнÑе ÑайÑÑ?', 992 b: ['ÐÑкÑÑÐ²Ð°ÐµÑ Ð»ÑбÑе ÑайÑÑ', '30 дней беÑплаÑно', 'Ðез ÑегиÑÑÑаÑии'], 993 cta: 'СкаÑаÑÑ GreatFireVPN', about: 'об ÑÑом зеÑкале', 994 a0: 'ÐеÑкало ÑайÑа ', a1: ', поддеÑживаеÑÑÑ ', a2: ' Ð´Ð»Ñ Ð¾Ð±Ð»ÐµÐ³ÑÐµÐ½Ð¸Ñ Ð´Ð¾ÑÑÑпа. ÐопÑоÑÑ: ', 995 g0: 'ÐеÑкало, поддеÑживаеÑÑÑ ', g1: ' Ð´Ð»Ñ Ð¾Ð±Ð»ÐµÐ³ÑÐµÐ½Ð¸Ñ Ð´Ð¾ÑÑÑпа. ÐопÑоÑÑ: ', 996 }, 997 fa: { 998 title: 'از Ø³Ø§ÛØªâÙØ§Û Ù Ø³Ø¯ÙØ¯ خست٠شدÙâØ§ÛØ¯Ø', 999 b: ['ÙÙ Ù Ø³Ø§ÛØªâÙØ§ را باز Ù ÛâÚ©ÙØ¯', 'Û³Û° Ø±ÙØ² راÛگاÙ', 'بدÙÙ ÙÛØ§Ø² ب٠ثبتâÙØ§Ù '], 1000 cta: 'Ø¯Ø±ÛØ§Ùت GreatFireVPN', about: 'دربار٠اÛ٠آÛÙÙ', 1001 a0: 'Ø¢ÛÙÙâØ§Û Ø§Ø² ', a1: ' Ú©Ù ØªÙØ³Ø· ', a2: ' Ø¨Ø±Ø§Û Ø¯Ø³ØªØ±Ø³Û Ø¢Ø³Ø§ÙâØªØ± ادار٠٠ÛâØ´ÙØ¯. Ø³ÙØ§Ùات: ', 1002 g0: 'Ø¢ÛÙÙâØ§Û Ú©Ù ØªÙØ³Ø· ', g1: ' Ø¨Ø±Ø§Û Ø¯Ø³ØªØ±Ø³Û Ø¢Ø³Ø§ÙâØªØ± ادار٠٠ÛâØ´ÙØ¯. Ø³ÙØ§Ùات: ', 1003 }, 1004 }; 1005 1006 function fillCardAbout(el, ct) { 1007 const email = lnk('mailto:[email protected]', '[email protected]'); 1008 const gf = lnk('https://greatfire.org', tr.gf); 1009 if (origin) { 1010 el.append(ct.a0, lnk('https://' + origin + originPath(), origin), ct.a1, gf, ct.a2, email); 1011 } else { 1012 el.append(ct.g0, gf, ct.g1, email); 1013 } 1014 } 1015 1016 function buildCard() { 1017 const ct = CARD_T[lang] || CARD_T.en; 1018 const rtl = lang === 'fa'; 1019 1020 const card = document.createElement('div'); 1021 card.id = BANNER_ID; 1022 card.style.cssText = [ 1023 'position:fixed', isBottom ? 'bottom:14px' : 'top:14px', 1024 'z-index:2147483647', 'box-sizing:border-box', 1025 isMobile ? 'left:10px' : (rtl ? 'left:14px' : 'right:14px'), 1026 isMobile ? 'right:10px' : 'width:280px', 1027 'background:#101828', 'color:#eef2ff', 'border-radius:12px', 'padding:14px 16px', 1028 'box-shadow:0 10px 30px rgba(0,0,0,0.45)', 1029 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', 1030 'font-size:13px', 'line-height:1.5', 1031 rtl ? 'direction:rtl' : 'direction:ltr', 1032 // Slide-in is applied after insertion so the card catches the eye; it 1033 // travels away from the edge it is anchored to. 1034 'opacity:0', 'transform:translateY(' + (isBottom ? '12px' : '-12px') + ')', 1035 'transition:opacity .28s ease,transform .28s ease', 1036 ].join(';'); 1037 1038 const head = document.createElement('div'); 1039 head.style.cssText = 'display:flex;align-items:flex-start;gap:10px'; 1040 head.innerHTML = 1041 '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" style="flex-shrink:0;margin-top:1px">' + 1042 '<path d="M12 2l8 3v6c0 5-3.4 9.4-8 11-4.6-1.6-8-6-8-11V5l8-3z" fill="#22c55e"/>' + 1043 '<path d="M8.5 12l2.3 2.3L15.5 9.6" stroke="#06230f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>' + 1044 '</svg>'; 1045 const title = document.createElement('div'); 1046 title.textContent = ct.title; 1047 title.style.cssText = 'font-weight:800;font-size:14px;line-height:1.35;flex:1;padding-' + (rtl ? 'left' : 'right') + ':18px'; 1048 head.append(title); 1049 1050 const list = document.createElement('ul'); 1051 list.style.cssText = 'list-style:none;margin:10px 0 12px;padding:0;color:#c3cbe4'; 1052 ct.b.forEach((text) => { 1053 const li = document.createElement('li'); 1054 li.style.cssText = 'margin:4px 0;display:flex;gap:7px;align-items:baseline'; 1055 const tick = document.createElement('span'); 1056 tick.textContent = '\u2713'; 1057 tick.style.cssText = 'color:#22c55e;font-weight:800;flex-shrink:0'; 1058 const label = document.createElement('span'); 1059 label.textContent = text; 1060 li.append(tick, label); 1061 list.append(li); 1062 }); 1063 1064 const cta = document.createElement('a'); 1065 cta.href = vpnPath; 1066 cta.textContent = ct.cta; 1067 cta.target = '_blank'; 1068 cta.rel = 'noopener noreferrer'; 1069 cta.style.cssText = [ 1070 'display:block', 'text-align:center', 'background:#22c55e', 'color:#06230f', 1071 'font-weight:800', 'font-size:13.5px', 'padding:9px 0', 'border-radius:8px', 1072 'text-decoration:none', 1073 ].join(';'); 1074 cta.onmouseover = () => { cta.style.background = '#4ade80'; }; 1075 cta.onmouseout = () => { cta.style.background = '#22c55e'; }; 1076 1077 const aboutPanel = document.createElement('div'); 1078 aboutPanel.style.cssText = [ 1079 'display:none', 'margin-top:10px', 'padding-top:10px', 1080 'border-top:1px solid #26304a', 'color:#aab3cc', 'font-size:11.5px', 'line-height:1.5', 1081 ].join(';'); 1082 fillCardAbout(aboutPanel, ct); 1083 1084 const aboutBtn = document.createElement('button'); 1085 aboutBtn.type = 'button'; 1086 aboutBtn.textContent = ct.about; 1087 aboutBtn.style.cssText = [ 1088 'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:11.5px', 1089 'color:#9aa4c2', 'text-decoration:underline', 'cursor:pointer', 1090 ].join(';'); 1091 aboutBtn.onmouseover = () => { aboutBtn.style.color = '#e6ebfa'; }; 1092 aboutBtn.onmouseout = () => { aboutBtn.style.color = '#9aa4c2'; }; 1093 aboutBtn.onclick = () => { 1094 aboutPanel.style.display = aboutPanel.style.display === 'none' ? 'block' : 'none'; 1095 }; 1096 1097 const byline = document.createElement('span'); 1098 byline.textContent = 'GreatFire.org';
1099 byline.style.cssText = 'color:#78829e;font-size:11px'; 1100 1101 const foot = document.createElement('div'); 1102 foot.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;margin-top:9px'; 1103 foot.append(aboutBtn, byline); 1104 1105 const close = document.createElement('button'); 1106 close.type = 'button'; 1107 close.textContent = '\u00D7'; 1108 close.setAttribute('aria-label', 'Dismiss'); 1109 close.style.cssText = [ 1110 'position:absolute', 'top:8px', rtl ? 'left:10px' : 'right:10px', 1111 'background:none', 'border:none', 'color:inherit', 'font-size:17px', 'line-height:1', 1112 'cursor:pointer', 'padding:0 4px', 'opacity:0.6', 1113 ].join(';'); 1114 close.onmouseover = () => { close.style.opacity = '1'; }; 1115 close.onmouseout = () => { close.style.opacity = '0.6'; }; 1116 close.onclick = () => { card.style.display = 'none'; }; 1117 1118 card.append(close, head, list, cta, foot, aboutPanel); 1119 return card; 1120 } 1121 1122 function insertCard() { 1123 const card = buildCard(); 1124 document.body.appendChild(card); 1125 requestAnimationFrame(() => { 1126 card.style.opacity = '1'; 1127 card.style.transform = 'translateY(0)'; 1128 }); 1129 } 1130 1131 // ââ Sheet variant (mobile) ââââââââââââââââââââââââââââââââââââââââââââââââ
1132 // The card's words, benefits and button, flush to the bottom edge of the 1133 // phone as a native-style sheet: thumb-reachable, edge-to-edge, and with a 1134 // button around a quarter taller. Everything else is held constant, so a win 1135 // here belongs to placement and target size rather than to the copy. 1136 1137 function buildSheet() { 1138 const ct = CARD_T[lang] || CARD_T.en; 1139 const rtl = lang === 'fa'; 1140 1141 const sheet = document.createElement('div'); 1142 sheet.id = BANNER_ID; 1143 sheet.style.cssText = [ 1144 // Bottom regardless of the mirror's data-position: the whole point of 1145 // this arm is the thumb, and on a phone the thumb is at the bottom. That 1146 // is a deliberate override for the twelve mirrors configured top, and 1147 // utm_content still records which setting the visitor was under. 1148 'position:fixed', 'bottom:0', 'left:0', 'right:0', 1149 'z-index:2147483647', 'box-sizing:border-box', 1150 'background:#101828', 'color:#eef2ff', 1151 'border-radius:18px 18px 0 0', 1152 // env() keeps the button clear of the iOS home indicator; browsers 1153 // without it fall back to the flat 18px. 1154 'padding:12px 18px calc(18px + env(safe-area-inset-bottom, 0px))', 1155 'box-shadow:0 -12px 34px rgba(0,0,0,0.5)', 1156 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', 1157 'font-size:13.5px', 'line-height:1.5', 1158 rtl ? 'direction:rtl' : 'direction:ltr', 1159 // Slides up out of the edge it is attached to, the way a sheet does. 1160 'opacity:0', 'transform:translateY(100%)', 1161 'transition:opacity .3s ease,transform .3s ease', 1162 ].join(';'); 1163 1164 // Grab bar: no drag behaviour behind it, it is there because it is the 1165 // signal people read as "this panel belongs to the bottom of the screen". 1166 const grab = document.createElement('div'); 1167 grab.setAttribute('aria-hidden', 'true'); 1168 grab.style.cssText = 'width:40px;height:4px;border-radius:2px;background:#39435e;margin:0 auto 12px'; 1169 1170 const title = document.createElement('div'); 1171 title.textContent = ct.title; 1172 title.style.cssText = 'font-weight:800;font-size:16px;line-height:1.35;padding-' + (rtl ? 'left' : 'right') + ':28px'; 1173 1174 const list = document.createElement('ul'); 1175 list.style.cssText = 'list-style:none;margin:10px 0 14px;padding:0;color:#c3cbe4'; 1176 ct.b.forEach((text) => { 1177 const li = document.createElement('li'); 1178 li.style.cssText = 'margin:5px 0;display:flex;gap:8px;align-items:baseline'; 1179 const tick = document.createElement('span'); 1180 tick.textContent = '\u2713'; 1181 tick.style.cssText = 'color:#22c55e;font-weight:800;flex-shrink:0'; 1182 const label = document.createElement('span'); 1183 label.textContent = text; 1184 li.append(tick, label); 1185 list.append(li); 1186 }); 1187 1188 const cta = document.createElement('a'); 1189 cta.href = vpnPath; 1190 cta.textContent = ct.cta; 1191 cta.target = '_blank'; 1192 cta.rel = 'noopener noreferrer'; 1193 cta.style.cssText = [ 1194 'display:block', 'text-align:center', 'background:#22c55e', 'color:#06230f', 1195 'font-weight:800', 'font-size:15px', 'padding:13px 0', 'border-radius:10px', 1196 'text-decoration:none', 1197 ].join(';'); 1198 1199 const aboutPanel = document.createElement('div'); 1200 aboutPanel.style.cssText = [ 1201 'display:none', 'margin-top:10px', 'padding-top:10px', 1202 'border-top:1px solid #26304a', 'color:#aab3cc', 'font-size:11.5px', 'line-height:1.5', 1203 ].join(';'); 1204 fillCardAbout(aboutPanel, ct); 1205 1206 const aboutBtn = document.createElement('button'); 1207 aboutBtn.type = 'button'; 1208 aboutBtn.textContent = ct.about; 1209 aboutBtn.style.cssText = [ 1210 'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:11.5px', 1211 'color:#9aa4c2', 'text-decoration:underline', 'cursor:pointer', 1212 ].join(';'); 1213 aboutBtn.onclick = () => { 1214 aboutPanel.style.display = aboutPanel.style.display === 'none' ? 'block' : 'none'; 1215 }; 1216 1217 const byline = document.createElement('span'); 1218 byline.textContent = 'GreatFire.org'; 1219 byline.style.cssText = 'color:#78829e;font-size:11px'; 1220 1221 const foot = document.createElement('div'); 1222 foot.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px'; 1223 foot.append(aboutBtn, byline); 1224 1225 const close = document.createElement('button'); 1226 close.type = 'button'; 1227 close.textContent = '\u00D7'; 1228 close.setAttribute('aria-label', 'Dismiss'); 1229 close.style.cssText = [ 1230 'position:absolute', 'top:12px', rtl ? 'left:14px' : 'right:14px', 1231 'background:none', 'border:none', 'color:inherit', 'font-size:19px', 'line-height:1', 1232 'cursor:pointer', 'padding:2px 6px', 'opacity:0.6', 1233 ].join(';'); 1234 close.onclick = () => { sheet.style.display = 'none'; }; 1235 1236 sheet.append(close, grab, title, list, cta, foot, aboutPanel); 1237 return sheet; 1238 } 1239 1240 function insertSheet() { 1241 const sheet = buildSheet(); 1242 document.body.appendChild(sheet); 1243 requestAnimationFrame(() => { 1244 sheet.style.opacity = '1'; 1245 sheet.style.transform = 'translateY(0)'; 1246 }); 1247 } 1248 1249 // ââ Mirror notice âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1250 // Shown instead of the banner where the banner is suppressed (/vpn, /fb).
1251 // Suppressing the promo there is right â nobody needs the VPN advertised to 1252 // them on the VPN site â but the banner was also the only thing disclosing 1253 // that the page is a mirror, and that disclosure has to survive on its own. 1254 // The mirrored page is itself a sales page for the product, so this is 1255 // deliberately quiet: grey rather than green, one line, no call to action. 1256 // Dismissing collapses it to a corner chip rather than deleting it, so a 1257 // returning visitor isn't told the same thing every visit but can still find 1258 // it. Not an A/B impression â those pageviews stay tagged 'bv:none'. 1259 1260 const NOTICE_COOKIE = 'smm_mirror_notice'; // '' | 'chip' 1261 1262 const NOTICE_T = { 1263 en: { 1264 m0: 'You are reading a mirror of ', 1265 m1: ', run by GreatFire.org so it can be reached where it is blocked.', 1266 g: 'You are reading a mirror, run by GreatFire.org so this site can be reached where it is blocked.', 1267 d0: 'The page itself is unchanged â only the address is different. Mirrors can be blocked too; if this one stops working, email ', 1268 d1: '.', 1269 more: 'details', chip: 'mirror', 1270 }, 1271 zh: { 1272 m0: 'æ¨æ£å¨è®¿é® ', 1273 m1: ' çéåç«ç¹ï¼ç± GreatFire.org è¿è¥ï¼ä»¥ä¾¿å¨è¢«å±è½çå°åºä»è½è®¿é®ã', 1274 g: 'æ¬ç«æ¯ç± GreatFire.org è¿è¥çéåç«ç¹ï¼ä»¥ä¾¿å¨è¢«å±è½çå°åºä»è½è®¿é®ã', 1275 d0: '页é¢å 容æªä½ä»»ä½ä¿®æ¹ï¼åªæ¯ç½åä¸åãéååæ ·å¯è½è¢«å±è½ï¼å¦ææ¬ç«æ æ³è®¿é®ï¼è¯·åéé®ä»¶è³ ', 1276 d1: 'ã', 1277 more: '详æ ', chip: 'éå', 1278 }, 1279 ru: { 1280 m0: 'ÐÑ ÑиÑаеÑе зеÑкало ÑайÑа ', 1281 m1: ', его поддеÑÐ¶Ð¸Ð²Ð°ÐµÑ GreatFire.org, ÑÑÐ¾Ð±Ñ ÑÐ°Ð¹Ñ Ð±Ñл доÑÑÑпен Ñам, где он заблокиÑован.', 1282 g: 'ÐÑ ÑиÑаеÑе зеÑкало, коÑоÑое поддеÑÐ¶Ð¸Ð²Ð°ÐµÑ GreatFire.org, ÑÑÐ¾Ð±Ñ ÑÐ°Ð¹Ñ Ð±Ñл доÑÑÑпен Ñам, где он заблокиÑован.', 1283 d0: 'СодеÑжимое ÑÑÑаниÑÑ Ð½Ðµ изменено â оÑлиÑаеÑÑÑ ÑолÑко адÑеÑ. ÐеÑкала Ñоже блокиÑÑÑÑ; еÑли ÑÑо пеÑеÑÑÐ°Ð½ÐµÑ ÑабоÑаÑÑ, напиÑиÑе на ', 1284 d1: '.', 1285 more: 'подÑобнее', chip: 'зеÑкало', 1286 }, 1287 fa: { 1288 m0: 'ش٠ا در ØØ§Ù Ø®ÙØ§Ùد٠آÛÙÙâØ§Û Ø§Ø² ', 1289 m1: ' ÙØ³ØªÛد Ú©Ù ØªÙØ³Ø· GreatFire.org ادار٠٠ÛâØ´ÙØ¯ تا در جاÛÛ Ú©Ù Ù Ø³Ø¯ÙØ¯ است در دسترس باشد.', 1290 g: 'ش٠ا در ØØ§Ù Ø®ÙØ§Ùد٠آÛÙÙâØ§Û ÙØ³ØªÛد Ú©Ù ØªÙØ³Ø· GreatFire.org ادار٠٠ÛâØ´ÙØ¯ تا اÛÙ Ø³Ø§ÛØª در جاÛÛ Ú©Ù Ù Ø³Ø¯ÙØ¯ است در دسترس باشد.', 1291 d0: 'Ù ØØªÙØ§Û ØµÙØÙ ØªØºÛÛØ±Û Ùکرد٠٠تÙÙØ§ ÙØ´Ø§ÙÛ Ø¢Ù Ù ØªÙØ§Ùت است. Ø¢ÛÙÙâÙØ§ Ù٠٠٠ک٠است Ù Ø³Ø¯ÙØ¯ Ø´ÙÙØ¯Ø اگر اÛÙ ÙØ´Ø§ÙÛ Ú©Ø§Ø± ÙÚ©Ø±Ø¯Ø Ø¨Ù ', 1292 d1: ' اÛÙ Û٠بزÙÛØ¯.', 1293 more: 'Ø¬Ø²Ø¦ÛØ§Øª', chip: 'Ø¢ÛÙÙ', 1294 }, 1295 }; 1296 1297 const nt = NOTICE_T[lang] || NOTICE_T.en; 1298 const noticeRtl = lang === 'fa'; 1299 1300 // The origin is named but not linked: the visitor is here precisely because 1301 // that address doesn't resolve for them, so a link would mostly produce 1302 // timeouts. 1303 function fillNoticeMessage(el) { 1304 if (!origin) { el.append(nt.g); return; } 1305 const site = document.createElement('b'); 1306 site.textContent = origin; 1307 site.style.cssText = 'color:#f0f4ff;font-weight:700'; 1308 el.append(nt.m0, site, nt.m1); 1309 } 1310 1311 function fillNoticeDetail(el) { 1312 el.append(nt.d0, lnk('mailto:[email protected]', '[email protected]'), nt.d1); 1313 } 1314 1315 let noticeEl = null; 1316 let noticeBasePad = 0; 1317 1318 function applyNoticePad() { 1319 if (!noticeEl || noticeEl.style.display === 'none') return; 1320 document.body.style[padProp] = (noticeBasePad + noticeEl.offsetHeight) + 'px'; 1321 } 1322 1323 function buildNoticeStrip() { 1324 const strip = document.createElement('div'); 1325 strip.id = NOTICE_ID; 1326 strip.style.cssText = [ 1327 'position:fixed', isBottom ? 'bottom:0' : 'top:0', 'left:0', 'width:100%', 1328 'z-index:2147483647', 'box-sizing:border-box', 1329 'background:#1e2536', 'color:#c9d2e6', 1330 isBottom ? 'border-top:1px solid #303a52' : 'border-bottom:1px solid #303a52', 1331 'padding:8px 14px', 'display:flex', 'align-items:center', 'flex-wrap:wrap', 'gap:10px', 1332 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', 1333 'font-size:12.5px', 'line-height:1.5', 1334 noticeRtl ? 'direction:rtl' : 'direction:ltr', 1335 ].join(';'); 1336 1337 const icon = document.createElement('span'); 1338 icon.textContent = '\u24D8'; 1339 icon.setAttribute('aria-hidden', 'true'); 1340 icon.style.cssText = 'flex-shrink:0;
1340opacity:0.75'; 1341 1342 const msg = document.createElement('span'); 1343 msg.style.cssText = 'flex:1;min-width:0'; 1344 fillNoticeMessage(msg); 1345 1346 const detail = document.createElement('div'); 1347 detail.style.cssText = [ 1348 'display:none', 'width:100%', 'color:#93a0bd', 'font-size:11.5px', 1349 'border-top:1px solid #303a52', 'padding-top:8px', 1350 ].join(';'); 1351 fillNoticeDetail(detail); 1352 1353 const more = document.createElement('button'); 1354 more.type = 'button'; 1355 more.textContent = nt.more; 1356 more.style.cssText = [ 1357 'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:12px', 1358 'color:#8fa4d4', 'text-decoration:underline', 'cursor:pointer', 'white-space:nowrap', 1359 ].join(';'); 1360 more.onmouseover = () => { more.style.color = '#dfe7fa'; }; 1361 more.onmouseout = () => { more.style.color = '#8fa4d4'; }; 1362 more.onclick = () => { 1363 detail.style.display = detail.style.display === 'none' ? 'block' : 'none'; 1364 applyNoticePad(); // the detail is a second row, so the strip just changed height 1365 }; 1366 1367 const close = document.createElement('button'); 1368 close.type = 'button'; 1369 close.textContent = '\u00D7'; 1370 close.setAttribute('aria-label', 'Dismiss'); 1371 close.style.cssText = [ 1372 'background:none', 'border:none', 'color:inherit', 'font-size:17px', 'line-height:1', 1373 'cursor:pointer', 'padding:0 4px', 'flex-shrink:0', 'opacity:0.6', 1374 ].join(';'); 1375 close.onmouseover = () => { close.style.opacity = '1'; }; 1376 close.onmouseout = () => { close.style.opacity = '0.6'; }; 1377 close.onclick = () => { 1378 setCookie(NOTICE_COOKIE, 'chip', 90); 1379 strip.style.display = 'none'; 1380 document.body.style[padProp] = noticeBasePad ? noticeBasePad + 'px' : ''; 1381 insertChip(); 1382 }; 1383 1384 strip.append(icon, msg, more, close, detail); 1385 return strip; 1386 } 1387 1388 function insertChip() { 1389 if (document.getElementById(NOTICE_CHIP_ID)) return; 1390 1391 const wrap = document.createElement('div'); 1392 wrap.id = NOTICE_CHIP_ID; 1393 wrap.style.cssText = [ 1394 // Always the bottom corner, whatever the strip's position was: it is 1395 // small enough not to matter there and it stays clear of site headers. 1396 'position:fixed', 'bottom:12px', noticeRtl ? 'right:12px' : 'left:12px', 1397 'z-index:2147483647', 'display:flex', 'flex-direction:column', 1398 noticeRtl ? 'align-items:flex-end' : 'align-items:flex-start', 'gap:8px', 1399 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', 1400 'font-size:11.5px', 'line-height:1.6', 1401 noticeRtl ? 'direction:rtl' : 'direction:ltr', 1402 ].join(';'); 1403 1404 const panel = document.createElement('div'); 1405 panel.style.cssText = [ 1406 'display:none', 'box-sizing:border-box', 'width:262px', 'max-width:calc(100vw - 24px)', 1407 'background:#1e2536', 'color:#a9b4cd', 'border:1px solid #3b4661', 'border-radius:10px', 1408 'padding:11px 13px', 'box-shadow:0 12px 32px rgba(0,0,0,0.35)', 1409 ].join(';'); 1410 const panelMsg = document.createElement('div'); 1411 fillNoticeMessage(panelMsg); 1412 const panelDetail = document.createElement('div'); 1413 panelDetail.style.cssText = 'margin-top:7px'; 1414 fillNoticeDetail(panelDetail); 1415 panel.append(panelMsg, panelDetail); 1416 1417 const chip = document.createElement('button'); 1418 chip.type = 'button'; 1419 chip.style.cssText = [ 1420 'display:inline-flex', 'align-items:center', 'gap:6px', 'cursor:pointer', 1421 'background:rgba(30,37,54,0.94)', 'color:#c9d2e6', 'border:1px solid #3b4661', 1422 'border-radius:999px', 'padding:5px 12px', 'font:inherit', 1423 ].join(';'); 1424 const chipIcon = document.createElement('span'); 1425 chipIcon.textContent = '\u24D8'; 1426 chipIcon.setAttribute('aria-hidden', 'true'); 1427 const chipLabel = document.createElement('span'); 1428 chipLabel.textContent = nt.chip; 1429 chip.append(chipIcon, chipLabel); 1430 chip.onclick = (e) => { 1431 e.stopPropagation(); 1432 panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; 1433 }; 1434 1435 // Anywhere else on the page closes it, the way a tooltip behaves. 1436 document.addEventListener('click', (e) => { 1437 if (panel.style.display === 'none' || wrap.contains(e.target)) return; 1438 panel.style.display = 'none'; 1439 }); 1440 1441 wrap.append(panel, chip); 1442 document.body.appendChild(wrap); 1443 } 1444
1445 function insertNotice() { 1446 if (getCookie(NOTICE_COOKIE) === 'chip') { insertChip(); return; } 1447 noticeEl = buildNoticeStrip(); 1448 document.body.insertBefore(noticeEl, document.body.firstChild); 1449 const v = parseInt(window.getComputedStyle(document.body)[padProp] || '0', 10); 1450 noticeBasePad = isNaN(v) ? 0 : v; 1451 applyNoticePad(); 1452 // Narrowing the window wraps the message onto a second line. 1453 window.addEventListener('resize', applyNoticePad); 1454 } 1455 1456 function insert() { 1457 // Operator opted this mirror out via data-banner="false": no card, no 1458 // sheet, no notice strip. 1459 if (bannerDisabled) return; 1460 // Neither arm attaches the old informational bar: round 1 retired it. Its 1461 // DOM is still built above because the survey is assembled inside it, and 1462 // the survey is switched off (SURVEYS_ENABLED) â reviving one means giving 1463 // the other a home first. 1464 if (bannerSuppressed) { insertNotice(); return; } 1465 if (showSheet) { insertSheet(); return; } 1466 insertCard(); 1467 } 1468 1469 if (document.body) { 1470 insert(); 1471 } else { 1472 document.addEventListener('DOMContentLoaded', insert); 1473 } 1474}()); 1475 1476// ââ Domain reachability probe âââââââââââââââââââââââââââââââââââââââââââââ 1477// Once per session, ask the backend for one sibling mirror domain (same 1478// 10-domain silo as the service-worker backup â no extra name exposure) and 1479// test whether this user's network can reach it. The sentinel file is served 1480// with Access-Control-Allow-Origin:* on every mirror. A same-origin control 1481// fetch filters out clients with broken connectivity or blocking extensions. 1482// Results are aggregated per (domain, country) server-side. 1483 1484(function () { 1485 const PROBE_PATH = '/test74382647832.txt'; 1486 const PROBE_TIMEOUT_MS = 10000; 1487 const TARGET_ENDPOINT = '/probe-target-8447291'; 1488 1489 if (!window.fetch || !window.AbortController) return; 1490 try { 1491 if (sessionStorage.getItem('smm_probe_done')) return; 1492 } catch (e) { 1493 return; // no sessionStorage -> can't rate-limit, don't probe 1494 } 1495 1496 function timedFetch(url) { 1497 const controller = new AbortController(); 1498 const t0 = Date.now(); 1499 const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); 1500 return fetch(url, { cache: 'no-store', signal: controller.signal }) 1501 .then((res) => { 1502 clearTimeout(timer); 1503 return { ok: res.ok, status: res.status, durationMs: Date.now() - t0, error: null }; 1504 }) 1505 .catch((e) => { 1506 clearTimeout(timer); 1507 return { 1508 ok: false, status: null, durationMs: Date.now() - t0, 1509 error: (e && e.name === 'AbortError') ? 'timeout' : 'network' 1510 }; 1511 }); 1512 } 1513 1514 function runProbe() { 1515 try { sessionStorage.setItem('smm_probe_done', '1'); } catch (e) {} 1516 1517 fetch(TARGET_ENDPOINT, { cache: 'no-store' }) 1518 .then((res) => res.json()) 1519 .then((data) => { 1520 const target = data && data.target; 1521 if (!target || typeof target !== 'string') return; 1522 // Defensive: never probe our own domain (server already excludes it). 1523 const hn = window.location.hostname.toLowerCase(); 1524 const tg = target.toLowerCase(); 1525 if (hn === tg || hn.endsWith('.' + tg)) return; 1526 1527 return timedFetch(PROBE_PATH).then((control) => 1528 timedFetch('https://' + target + PROBE_PATH).then((probe) => { 1529 sendEvent('domainProbe', { 1530 targetDomain: target, 1531 ok: probe.ok, 1532 status: probe.status, 1533 durationMs: probe.durationMs, 1534 error: probe.error, 1535 controlOk: control.ok, 1536 controlDurationMs: control.durationMs 1537 }); 1538 }) 1539 ); 1540 }) 1541 .catch(() => { /* probe infrastructure unavailable; stay silent */ }); 1542 } 1543 1544 // Wait for page load plus a small randomized delay so the probe never 1545 // competes with page rendering or clusters requests across a mirror. 1546 function schedule() { 1547 setTimeout(runProbe, 3000 + Math.floor(Math.random() * 3000)); 1548 } 1549 1550 if (document.readyState === 'complete') { 1551 schedule(); 1552 } else { 1553 window.addEventListener('load', schedule); 1554 } 1555}()); 1556
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.