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