From 6d2f72fe7849ad307b0e22ccf48a153115696c8b Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:55:00 +1100 Subject: [PATCH 01/26] Update content scope scripts to version 6.29.0 (#5242) - Automated content scope scripts dependency update This PR updates the content scope scripts dependency to the latest available version and copies the necessary files. If tests have failed, see https://app.asana.com/0/1202561462274611/1203986899650836/f for further information on what to do next. - [ ] All tests must pass Co-authored-by: daxmobile --- .../build/android/contentScope.js | 2114 +++++++++-------- .../android/pages/duckplayer/js/index.css | 21 +- .../android/pages/duckplayer/js/index.js | 485 ++-- package-lock.json | 27 +- package.json | 2 +- 5 files changed, 1279 insertions(+), 1370 deletions(-) diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index 71525ddea5b0..97de912ee16b 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -2,6 +2,7 @@ (function () { 'use strict'; + /* eslint-disable no-redeclare */ const Set$1 = globalThis.Set; const Reflect$1 = globalThis.Reflect; const customElementsGet = globalThis.customElements?.get.bind(globalThis.customElements); @@ -14,10 +15,11 @@ const URL$1 = globalThis.URL; const Proxy$1 = globalThis.Proxy; + /* eslint-disable no-redeclare, no-global-assign */ /* global cloneInto, exportFunction, false */ // Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox - // eslint-disable-next-line no-global-assign + let globalObj = typeof window === 'undefined' ? globalThis : window; let Error$1 = globalObj.Error; let messageSecret; @@ -253,7 +255,7 @@ // eslint-disable-next-line no-debugger debugger }, - // eslint-disable-next-line @typescript-eslint/no-empty-function + noop: () => { } }; @@ -770,6 +772,7 @@ /** * Tiny wrapper around performance.mark and performance.measure */ + // eslint-disable-next-line no-redeclare class PerformanceMark { /** * @param {string} name @@ -1460,55 +1463,60 @@ return sjcl.codec.hex.fromBits(hmac.encrypt(inputData)) } - function _typeof$2(obj) { "@babel/helpers - typeof"; return _typeof$2 = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof$2(obj); } function isJSONArray(value) { return Array.isArray(value); } function isJSONObject(value) { - return value !== null && _typeof$2(value) === 'object' && value.constructor === Object // do not match on classes or Array + return value !== null && typeof value === 'object' && (value.constructor === undefined || + // for example Object.create(null) + value.constructor.name === 'Object') // do not match on classes or Array ; } - function _typeof$1(obj) { "@babel/helpers - typeof"; return _typeof$1 = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof$1(obj); } - /** - * Test deep equality of two JSON values, objects, or arrays - */ // TODO: write unit tests + /** + * Test deep equality of two JSON values, objects, or arrays + */ + // TODO: write unit tests function isEqual(a, b) { // FIXME: this function will return false for two objects with the same keys // but different order of keys return JSON.stringify(a) === JSON.stringify(b); } - /** - * Get all but the last items from an array + /** + * Get all but the last items from an array */ // TODO: write unit tests function initial(array) { return array.slice(0, array.length - 1); } - /** - * Get the last item from an array + /** + * Get the last item from an array */ // TODO: write unit tests function last(array) { return array[array.length - 1]; } - /** - * Test whether a value is an Object or an Array (and not a primitive JSON value) + /** + * Test whether a value is an Object or an Array (and not a primitive JSON value) */ // TODO: write unit tests function isObjectOrArray(value) { - return _typeof$1(value) === 'object' && value !== null; + return typeof value === 'object' && value !== null; } - function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); } - function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } - function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } - function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } - function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } - function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } + /** + * Immutability helpers + * + * inspiration: + * + * https://www.npmjs.com/package/seamless-immutable + * https://www.npmjs.com/package/ih + * https://www.npmjs.com/package/mutatis + * https://github.com/mariocasciaro/object-path-immutable + */ /** * Shallow clone of an Object, Array, or value @@ -1517,10 +1525,10 @@ function shallowClone(value) { if (isJSONArray(value)) { // copy array items - var copy = value.slice(); + const copy = value.slice(); // copy all symbols - Object.getOwnPropertySymbols(value).forEach(function (symbol) { + Object.getOwnPropertySymbols(value).forEach(symbol => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore copy[symbol] = value[symbol]; @@ -1528,15 +1536,17 @@ return copy; } else if (isJSONObject(value)) { // copy object properties - var _copy = _objectSpread({}, value); + const copy = { + ...value + }; // copy all symbols - Object.getOwnPropertySymbols(value).forEach(function (symbol) { + Object.getOwnPropertySymbols(value).forEach(symbol => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - _copy[symbol] = value[symbol]; + copy[symbol] = value[symbol]; }); - return _copy; + return copy; } else { return value; } @@ -1553,7 +1563,7 @@ // return original object unchanged when the new value is identical to the old one return object; } else { - var updatedObject = shallowClone(object); + const updatedObject = shallowClone(object); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore updatedObject[key] = value; @@ -1567,8 +1577,8 @@ * @return Returns the field when found, or undefined when the path doesn't exist */ function getIn(object, path) { - var value = object; - var i = 0; + let value = object; + let i = 0; while (i < path.length) { if (isJSONObject(value)) { value = value[path[i]]; @@ -1599,19 +1609,19 @@ * @return Returns a new, updated object or array */ function setIn(object, path, value) { - var createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + let createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; if (path.length === 0) { return value; } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = setIn(object ? object[key] : undefined, path.slice(1), value, createPath); + const updatedValue = setIn(object ? object[key] : undefined, path.slice(1), value, createPath); if (isJSONObject(object) || isJSONArray(object)) { return applyProp(object, key, updatedValue); } else { if (createPath) { - var newObject = IS_INTEGER_REGEX.test(key) ? [] : {}; + const newObject = IS_INTEGER_REGEX.test(key) ? [] : {}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore newObject[key] = updatedValue; @@ -1621,7 +1631,7 @@ } } } - var IS_INTEGER_REGEX = /^\d+$/; + const IS_INTEGER_REGEX = /^\d+$/; /** * helper function to replace a nested property in an object with a new value @@ -1629,17 +1639,17 @@ * * @return Returns a new, updated object or array */ - function updateIn(object, path, callback) { + function updateIn(object, path, transform) { if (path.length === 0) { - return callback(object); + return transform(object); } if (!isObjectOrArray(object)) { throw new Error('Path doesn\'t exist'); } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = updateIn(object[key], path.slice(1), callback); + const updatedValue = updateIn(object[key], path.slice(1), transform); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return applyProp(object, key, updatedValue); @@ -1659,25 +1669,25 @@ throw new Error('Path does not exist'); } if (path.length === 1) { - var _key = path[0]; - if (!(_key in object)) { + const key = path[0]; + if (!(key in object)) { // key doesn't exist. return object unchanged return object; } else { - var updatedObject = shallowClone(object); + const updatedObject = shallowClone(object); if (isJSONArray(updatedObject)) { - updatedObject.splice(parseInt(_key), 1); + updatedObject.splice(parseInt(key), 1); } if (isJSONObject(updatedObject)) { - delete updatedObject[_key]; + delete updatedObject[key]; } return updatedObject; } } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = deleteIn(object[key], path.slice(1)); + const updatedValue = deleteIn(object[key], path.slice(1)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return applyProp(object, key, updatedValue); @@ -1690,13 +1700,13 @@ * insertAt({arr: [1,2,3]}, ['arr', '2'], 'inserted') // [1,2,'inserted',3] */ function insertAt(document, path, value) { - var parentPath = path.slice(0, path.length - 1); - var index = path[path.length - 1]; - return updateIn(document, parentPath, function (items) { + const parentPath = path.slice(0, path.length - 1); + const index = path[path.length - 1]; + return updateIn(document, parentPath, items => { if (!Array.isArray(items)) { throw new TypeError('Array expected at path ' + JSON.stringify(parentPath)); } - var updatedItems = shallowClone(items); + const updatedItems = shallowClone(items); updatedItems.splice(parseInt(index), 0, value); return updatedItems; }); @@ -1726,12 +1736,10 @@ * Parse a JSON Pointer */ function parseJSONPointer(pointer) { - var path = pointer.split('/'); + const path = pointer.split('/'); path.shift(); // remove the first empty entry - return path.map(function (p) { - return p.replace(/~1/g, '/').replace(/~0/g, '~'); - }); + return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~')); } /** @@ -1754,31 +1762,11 @@ * instead, the patch is applied in an immutable way */ function immutableJSONPatch(document, operations, options) { - var updatedDocument = document; - for (var i = 0; i < operations.length; i++) { + let updatedDocument = document; + for (let i = 0; i < operations.length; i++) { validateJSONPatchOperation(operations[i]); - var operation = operations[i]; - - // TODO: test before - if (options && options.before) { - var result = options.before(updatedDocument, operation); - if (result !== undefined) { - if (result.document !== undefined) { - updatedDocument = result.document; - } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (result.json !== undefined) { - // TODO: deprecated since v5.0.0. Cleanup this warning some day - throw new Error('Deprecation warning: returned object property ".json" has been renamed to ".document"'); - } - if (result.operation !== undefined) { - operation = result.operation; - } - } - } - var previousDocument = updatedDocument; - var path = parsePath(updatedDocument, operation.path); + let operation = operations[i]; + const path = parsePath(updatedDocument, operation.path); if (operation.op === 'add') { updatedDocument = add(updatedDocument, path, operation.value); } else if (operation.op === 'remove') { @@ -1794,14 +1782,6 @@ } else { throw new Error('Unknown JSONPatch operation ' + JSON.stringify(operation)); } - - // TODO: test after - if (options && options.after) { - var _result = options.after(updatedDocument, operation, previousDocument); - if (_result !== undefined) { - updatedDocument = _result; - } - } } return updatedDocument; } @@ -1835,12 +1815,12 @@ * Copy a value */ function copy(document, path, from) { - var value = getIn(document, from); + const value = getIn(document, from); if (isArrayItem(document, path)) { return insertAt(document, path, value); } else { - var _value = getIn(document, from); - return setIn(document, path, _value); + const value = getIn(document, from); + return setIn(document, path, value); } } @@ -1848,10 +1828,10 @@ * Move a value */ function move(document, path, from) { - var value = getIn(document, from); + const value = getIn(document, from); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var removedJson = deleteIn(document, from); + const removedJson = deleteIn(document, from); return isArrayItem(removedJson, path) ? insertAt(removedJson, path, value) : setIn(removedJson, path, value); } @@ -1861,21 +1841,21 @@ */ function test(document, path, value) { if (value === undefined) { - throw new Error("Test failed: no value provided (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed: no value provided (path: "${compileJSONPointer(path)}")`); } if (!existsIn(document, path)) { - throw new Error("Test failed: path not found (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed: path not found (path: "${compileJSONPointer(path)}")`); } - var actualValue = getIn(document, path); + const actualValue = getIn(document, path); if (!isEqual(actualValue, value)) { - throw new Error("Test failed, value differs (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed, value differs (path: "${compileJSONPointer(path)}")`); } } function isArrayItem(document, path) { if (path.length === 0) { return false; } - var parent = getIn(document, initial(path)); + const parent = getIn(document, initial(path)); return Array.isArray(parent); } @@ -1887,8 +1867,8 @@ if (last(path) !== '-') { return path; } - var parentPath = initial(path); - var parent = getIn(document, parentPath); + const parentPath = initial(path); + const parent = getIn(document, parentPath); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -1901,7 +1881,7 @@ */ function validateJSONPatchOperation(operation) { // TODO: write unit tests - var ops = ['add', 'remove', 'replace', 'copy', 'move', 'test']; + const ops = ['add', 'remove', 'replace', 'copy', 'move', 'test']; if (!ops.includes(operation.op)) { throw new Error('Unknown JSONPatch op ' + JSON.stringify(operation.op)); } @@ -2435,13 +2415,13 @@ }; // console.log('DEBUG: handler setup', { config, comparator }) - // eslint-disable-next-line no-undef + this.config.methods.addEventListener('message', idHandler); options?.signal?.addEventListener('abort', abortHandler); teardown = () => { // console.log('DEBUG: handler teardown', { config, comparator }) - // eslint-disable-next-line no-undef + this.config.methods.removeEventListener('message', idHandler); options?.signal?.removeEventListener('abort', abortHandler); }; @@ -2911,7 +2891,7 @@ * @param {any[]} args */ value: (...args) => { - // eslint-disable-next-line n/no-callback-literal + callback(...args); delete this.globals.window[randomMethodName]; } @@ -3863,8 +3843,10 @@ /** @type {boolean | undefined} */ #documentOriginIsTracker /** @type {Record | undefined} */ + // eslint-disable-next-line no-unused-private-class-members #bundledfeatureSettings /** @type {import('../../messaging').Messaging} */ + // eslint-disable-next-line no-unused-private-class-members #messaging /** @type {boolean} */ #isDebugFlagSet = false @@ -4046,7 +4028,7 @@ }) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + init (args) { } @@ -4059,7 +4041,7 @@ this.measure(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + load (args) { } @@ -4128,7 +4110,7 @@ } } - // eslint-disable-next-line @typescript-eslint/no-empty-function + update () { } @@ -4377,683 +4359,687 @@ } } - var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; - function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var alea$1 = {exports: {}}; - alea$1.exports; - - (function (module) { - // A port of an algorithm by Johannes Baagøe , 2010 - // http://baagoe.com/en/RandomMusings/javascript/ - // https://github.com/nquinlan/better-random-numbers-for-javascript-mirror - // Original work is under MIT license - - - // Copyright (C) 2010 by Johannes Baagøe - // - // Permission is hereby granted, free of charge, to any person obtaining a copy - // of this software and associated documentation files (the "Software"), to deal - // in the Software without restriction, including without limitation the rights - // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - // copies of the Software, and to permit persons to whom the Software is - // furnished to do so, subject to the following conditions: - // - // The above copyright notice and this permission notice shall be included in - // all copies or substantial portions of the Software. - // - // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - // THE SOFTWARE. - - - - (function(global, module, define) { - - function Alea(seed) { - var me = this, mash = Mash(); - - me.next = function() { - var t = 2091639 * me.s0 + me.c * 2.3283064365386963e-10; // 2^-32 - me.s0 = me.s1; - me.s1 = me.s2; - return me.s2 = t - (me.c = t | 0); - }; - - // Apply the seeding algorithm from Baagoe. - me.c = 1; - me.s0 = mash(' '); - me.s1 = mash(' '); - me.s2 = mash(' '); - me.s0 -= mash(seed); - if (me.s0 < 0) { me.s0 += 1; } - me.s1 -= mash(seed); - if (me.s1 < 0) { me.s1 += 1; } - me.s2 -= mash(seed); - if (me.s2 < 0) { me.s2 += 1; } - mash = null; - } - - function copy(f, t) { - t.c = f.c; - t.s0 = f.s0; - t.s1 = f.s1; - t.s2 = f.s2; - return t; - } - - function impl(seed, opts) { - var xg = new Alea(seed), - state = opts && opts.state, - prng = xg.next; - prng.int32 = function() { return (xg.next() * 0x100000000) | 0; }; - prng.double = function() { - return prng() + (prng() * 0x200000 | 0) * 1.1102230246251565e-16; // 2^-53 - }; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - function Mash() { - var n = 0xefc8249d; - - var mash = function(data) { - data = String(data); - for (var i = 0; i < data.length; i++) { - n += data.charCodeAt(i); - var h = 0.02519603282416938 * n; - n = h >>> 0; - h -= n; - h *= n; - n = h >>> 0; - h -= n; - n += h * 0x100000000; // 2^32 - } - return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 - }; - - return mash; - } - - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.alea = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (alea$1)); - - var aleaExports = alea$1.exports; + var alea = alea$1.exports; + + var hasRequiredAlea; + + function requireAlea () { + if (hasRequiredAlea) return alea$1.exports; + hasRequiredAlea = 1; + (function (module) { + // A port of an algorithm by Johannes Baagøe , 2010 + // http://baagoe.com/en/RandomMusings/javascript/ + // https://github.com/nquinlan/better-random-numbers-for-javascript-mirror + // Original work is under MIT license - + + // Copyright (C) 2010 by Johannes Baagøe + // + // Permission is hereby granted, free of charge, to any person obtaining a copy + // of this software and associated documentation files (the "Software"), to deal + // in the Software without restriction, including without limitation the rights + // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + // copies of the Software, and to permit persons to whom the Software is + // furnished to do so, subject to the following conditions: + // + // The above copyright notice and this permission notice shall be included in + // all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + // THE SOFTWARE. + + + + (function(global, module, define) { + + function Alea(seed) { + var me = this, mash = Mash(); + + me.next = function() { + var t = 2091639 * me.s0 + me.c * 2.3283064365386963e-10; // 2^-32 + me.s0 = me.s1; + me.s1 = me.s2; + return me.s2 = t - (me.c = t | 0); + }; + + // Apply the seeding algorithm from Baagoe. + me.c = 1; + me.s0 = mash(' '); + me.s1 = mash(' '); + me.s2 = mash(' '); + me.s0 -= mash(seed); + if (me.s0 < 0) { me.s0 += 1; } + me.s1 -= mash(seed); + if (me.s1 < 0) { me.s1 += 1; } + me.s2 -= mash(seed); + if (me.s2 < 0) { me.s2 += 1; } + mash = null; + } + + function copy(f, t) { + t.c = f.c; + t.s0 = f.s0; + t.s1 = f.s1; + t.s2 = f.s2; + return t; + } + + function impl(seed, opts) { + var xg = new Alea(seed), + state = opts && opts.state, + prng = xg.next; + prng.int32 = function() { return (xg.next() * 0x100000000) | 0; }; + prng.double = function() { + return prng() + (prng() * 0x200000 | 0) * 1.1102230246251565e-16; // 2^-53 + }; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + function Mash() { + var n = 0xefc8249d; + + var mash = function(data) { + data = String(data); + for (var i = 0; i < data.length; i++) { + n += data.charCodeAt(i); + var h = 0.02519603282416938 * n; + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 0x100000000; // 2^32 + } + return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 + }; + + return mash; + } + + + if (module && module.exports) { + module.exports = impl; + } else { + this.alea = impl; + } + + })( + alea, + module); + } (alea$1)); + return alea$1.exports; + } var xor128$1 = {exports: {}}; - xor128$1.exports; - - (function (module) { - // A Javascript implementaion of the "xor128" prng algorithm by - // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - me.x = 0; - me.y = 0; - me.z = 0; - me.w = 0; - - // Set up generator function. - me.next = function() { - var t = me.x ^ (me.x << 11); - me.x = me.y; - me.y = me.z; - me.z = me.w; - return me.w ^= (me.w >>> 19) ^ t ^ (t >>> 8); - }; - - if (seed === (seed | 0)) { - // Integer seed. - me.x = seed; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 64; k++) { - me.x ^= strseed.charCodeAt(k) | 0; - me.next(); - } - } - - function copy(f, t) { - t.x = f.x; - t.y = f.y; - t.z = f.z; - t.w = f.w; - return t; - } - - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xor128 = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xor128$1)); - - var xor128Exports = xor128$1.exports; + var xor128 = xor128$1.exports; + + var hasRequiredXor128; + + function requireXor128 () { + if (hasRequiredXor128) return xor128$1.exports; + hasRequiredXor128 = 1; + (function (module) { + // A Javascript implementaion of the "xor128" prng algorithm by + // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + me.x = 0; + me.y = 0; + me.z = 0; + me.w = 0; + + // Set up generator function. + me.next = function() { + var t = me.x ^ (me.x << 11); + me.x = me.y; + me.y = me.z; + me.z = me.w; + return me.w ^= (me.w >>> 19) ^ t ^ (t >>> 8); + }; + + if (seed === (seed | 0)) { + // Integer seed. + me.x = seed; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 64; k++) { + me.x ^= strseed.charCodeAt(k) | 0; + me.next(); + } + } + + function copy(f, t) { + t.x = f.x; + t.y = f.y; + t.z = f.z; + t.w = f.w; + return t; + } + + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xor128 = impl; + } + + })( + xor128, + module); + } (xor128$1)); + return xor128$1.exports; + } var xorwow$1 = {exports: {}}; - xorwow$1.exports; - - (function (module) { - // A Javascript implementaion of the "xorwow" prng algorithm by - // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - // Set up generator function. - me.next = function() { - var t = (me.x ^ (me.x >>> 2)); - me.x = me.y; me.y = me.z; me.z = me.w; me.w = me.v; - return (me.d = (me.d + 362437 | 0)) + - (me.v = (me.v ^ (me.v << 4)) ^ (t ^ (t << 1))) | 0; - }; - - me.x = 0; - me.y = 0; - me.z = 0; - me.w = 0; - me.v = 0; - - if (seed === (seed | 0)) { - // Integer seed. - me.x = seed; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 64; k++) { - me.x ^= strseed.charCodeAt(k) | 0; - if (k == strseed.length) { - me.d = me.x << 10 ^ me.x >>> 4; - } - me.next(); - } - } - - function copy(f, t) { - t.x = f.x; - t.y = f.y; - t.z = f.z; - t.w = f.w; - t.v = f.v; - t.d = f.d; - return t; - } - - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xorwow = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xorwow$1)); - - var xorwowExports = xorwow$1.exports; + var xorwow = xorwow$1.exports; + + var hasRequiredXorwow; + + function requireXorwow () { + if (hasRequiredXorwow) return xorwow$1.exports; + hasRequiredXorwow = 1; + (function (module) { + // A Javascript implementaion of the "xorwow" prng algorithm by + // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + // Set up generator function. + me.next = function() { + var t = (me.x ^ (me.x >>> 2)); + me.x = me.y; me.y = me.z; me.z = me.w; me.w = me.v; + return (me.d = (me.d + 362437 | 0)) + + (me.v = (me.v ^ (me.v << 4)) ^ (t ^ (t << 1))) | 0; + }; + + me.x = 0; + me.y = 0; + me.z = 0; + me.w = 0; + me.v = 0; + + if (seed === (seed | 0)) { + // Integer seed. + me.x = seed; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 64; k++) { + me.x ^= strseed.charCodeAt(k) | 0; + if (k == strseed.length) { + me.d = me.x << 10 ^ me.x >>> 4; + } + me.next(); + } + } + + function copy(f, t) { + t.x = f.x; + t.y = f.y; + t.z = f.z; + t.w = f.w; + t.v = f.v; + t.d = f.d; + return t; + } + + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xorwow = impl; + } + + })( + xorwow, + module); + } (xorwow$1)); + return xorwow$1.exports; + } var xorshift7$1 = {exports: {}}; - xorshift7$1.exports; - - (function (module) { - // A Javascript implementaion of the "xorshift7" algorithm by - // François Panneton and Pierre L'ecuyer: - // "On the Xorgshift Random Number Generators" - // http://saluc.engr.uconn.edu/refs/crypto/rng/panneton05onthexorshift.pdf - - (function(global, module, define) { - - function XorGen(seed) { - var me = this; - - // Set up generator function. - me.next = function() { - // Update xor generator. - var X = me.x, i = me.i, t, v; - t = X[i]; t ^= (t >>> 7); v = t ^ (t << 24); - t = X[(i + 1) & 7]; v ^= t ^ (t >>> 10); - t = X[(i + 3) & 7]; v ^= t ^ (t >>> 3); - t = X[(i + 4) & 7]; v ^= t ^ (t << 7); - t = X[(i + 7) & 7]; t = t ^ (t << 13); v ^= t ^ (t << 9); - X[i] = v; - me.i = (i + 1) & 7; - return v; - }; - - function init(me, seed) { - var j, X = []; - - if (seed === (seed | 0)) { - // Seed state array using a 32-bit integer. - X[0] = seed; - } else { - // Seed state using a string. - seed = '' + seed; - for (j = 0; j < seed.length; ++j) { - X[j & 7] = (X[j & 7] << 15) ^ - (seed.charCodeAt(j) + X[(j + 1) & 7] << 13); - } - } - // Enforce an array length of 8, not all zeroes. - while (X.length < 8) X.push(0); - for (j = 0; j < 8 && X[j] === 0; ++j); - if (j == 8) X[7] = -1; else X[j]; - - me.x = X; - me.i = 0; - - // Discard an initial 256 values. - for (j = 256; j > 0; --j) { - me.next(); - } - } - - init(me, seed); - } - - function copy(f, t) { - t.x = f.x.slice(); - t.i = f.i; - return t; - } - - function impl(seed, opts) { - if (seed == null) seed = +(new Date); - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (state.x) copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xorshift7 = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xorshift7$1)); - - var xorshift7Exports = xorshift7$1.exports; + var xorshift7 = xorshift7$1.exports; + + var hasRequiredXorshift7; + + function requireXorshift7 () { + if (hasRequiredXorshift7) return xorshift7$1.exports; + hasRequiredXorshift7 = 1; + (function (module) { + // A Javascript implementaion of the "xorshift7" algorithm by + // François Panneton and Pierre L'ecuyer: + // "On the Xorgshift Random Number Generators" + // http://saluc.engr.uconn.edu/refs/crypto/rng/panneton05onthexorshift.pdf + + (function(global, module, define) { + + function XorGen(seed) { + var me = this; + + // Set up generator function. + me.next = function() { + // Update xor generator. + var X = me.x, i = me.i, t, v; + t = X[i]; t ^= (t >>> 7); v = t ^ (t << 24); + t = X[(i + 1) & 7]; v ^= t ^ (t >>> 10); + t = X[(i + 3) & 7]; v ^= t ^ (t >>> 3); + t = X[(i + 4) & 7]; v ^= t ^ (t << 7); + t = X[(i + 7) & 7]; t = t ^ (t << 13); v ^= t ^ (t << 9); + X[i] = v; + me.i = (i + 1) & 7; + return v; + }; + + function init(me, seed) { + var j, X = []; + + if (seed === (seed | 0)) { + // Seed state array using a 32-bit integer. + X[0] = seed; + } else { + // Seed state using a string. + seed = '' + seed; + for (j = 0; j < seed.length; ++j) { + X[j & 7] = (X[j & 7] << 15) ^ + (seed.charCodeAt(j) + X[(j + 1) & 7] << 13); + } + } + // Enforce an array length of 8, not all zeroes. + while (X.length < 8) X.push(0); + for (j = 0; j < 8 && X[j] === 0; ++j); + if (j == 8) X[7] = -1; else X[j]; + + me.x = X; + me.i = 0; + + // Discard an initial 256 values. + for (j = 256; j > 0; --j) { + me.next(); + } + } + + init(me, seed); + } + + function copy(f, t) { + t.x = f.x.slice(); + t.i = f.i; + return t; + } + + function impl(seed, opts) { + if (seed == null) seed = +(new Date); + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (state.x) copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xorshift7 = impl; + } + + })( + xorshift7, + module); + } (xorshift7$1)); + return xorshift7$1.exports; + } var xor4096$1 = {exports: {}}; - xor4096$1.exports; - - (function (module) { - // A Javascript implementaion of Richard Brent's Xorgens xor4096 algorithm. - // - // This fast non-cryptographic random number generator is designed for - // use in Monte-Carlo algorithms. It combines a long-period xorshift - // generator with a Weyl generator, and it passes all common batteries - // of stasticial tests for randomness while consuming only a few nanoseconds - // for each prng generated. For background on the generator, see Brent's - // paper: "Some long-period random number generators using shifts and xors." - // http://arxiv.org/pdf/1004.3115v1.pdf - // - // Usage: - // - // var xor4096 = require('xor4096'); - // random = xor4096(1); // Seed with int32 or string. - // assert.equal(random(), 0.1520436450538547); // (0, 1) range, 53 bits. - // assert.equal(random.int32(), 1806534897); // signed int32, 32 bits. - // - // For nonzero numeric keys, this impelementation provides a sequence - // identical to that by Brent's xorgens 3 implementaion in C. This - // implementation also provides for initalizing the generator with - // string seeds, or for saving and restoring the state of the generator. - // - // On Chrome, this prng benchmarks about 2.1 times slower than - // Javascript's built-in Math.random(). - - (function(global, module, define) { - - function XorGen(seed) { - var me = this; - - // Set up generator function. - me.next = function() { - var w = me.w, - X = me.X, i = me.i, t, v; - // Update Weyl generator. - me.w = w = (w + 0x61c88647) | 0; - // Update xor generator. - v = X[(i + 34) & 127]; - t = X[i = ((i + 1) & 127)]; - v ^= v << 13; - t ^= t << 17; - v ^= v >>> 15; - t ^= t >>> 12; - // Update Xor generator array state. - v = X[i] = v ^ t; - me.i = i; - // Result is the combination. - return (v + (w ^ (w >>> 16))) | 0; - }; - - function init(me, seed) { - var t, v, i, j, w, X = [], limit = 128; - if (seed === (seed | 0)) { - // Numeric seeds initialize v, which is used to generates X. - v = seed; - seed = null; - } else { - // String seeds are mixed into v and X one character at a time. - seed = seed + '\0'; - v = 0; - limit = Math.max(limit, seed.length); - } - // Initialize circular array and weyl value. - for (i = 0, j = -32; j < limit; ++j) { - // Put the unicode characters into the array, and shuffle them. - if (seed) v ^= seed.charCodeAt((j + 32) % seed.length); - // After 32 shuffles, take v as the starting w value. - if (j === 0) w = v; - v ^= v << 10; - v ^= v >>> 15; - v ^= v << 4; - v ^= v >>> 13; - if (j >= 0) { - w = (w + 0x61c88647) | 0; // Weyl. - t = (X[j & 127] ^= (v + w)); // Combine xor and weyl to init array. - i = (0 == t) ? i + 1 : 0; // Count zeroes. - } - } - // We have detected all zeroes; make the key nonzero. - if (i >= 128) { - X[(seed && seed.length || 0) & 127] = -1; - } - // Run the generator 512 times to further mix the state before using it. - // Factoring this as a function slows the main generator, so it is just - // unrolled here. The weyl generator is not advanced while warming up. - i = 127; - for (j = 4 * 128; j > 0; --j) { - v = X[(i + 34) & 127]; - t = X[i = ((i + 1) & 127)]; - v ^= v << 13; - t ^= t << 17; - v ^= v >>> 15; - t ^= t >>> 12; - X[i] = v ^ t; - } - // Storing state as object members is faster than using closure variables. - me.w = w; - me.X = X; - me.i = i; - } - - init(me, seed); - } - - function copy(f, t) { - t.i = f.i; - t.w = f.w; - t.X = f.X.slice(); - return t; - } - function impl(seed, opts) { - if (seed == null) seed = +(new Date); - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (state.X) copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xor4096 = impl; - } - - })( - commonjsGlobal, // window object or global - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xor4096$1)); - - var xor4096Exports = xor4096$1.exports; + var xor4096 = xor4096$1.exports; + + var hasRequiredXor4096; + + function requireXor4096 () { + if (hasRequiredXor4096) return xor4096$1.exports; + hasRequiredXor4096 = 1; + (function (module) { + // A Javascript implementaion of Richard Brent's Xorgens xor4096 algorithm. + // + // This fast non-cryptographic random number generator is designed for + // use in Monte-Carlo algorithms. It combines a long-period xorshift + // generator with a Weyl generator, and it passes all common batteries + // of stasticial tests for randomness while consuming only a few nanoseconds + // for each prng generated. For background on the generator, see Brent's + // paper: "Some long-period random number generators using shifts and xors." + // http://arxiv.org/pdf/1004.3115v1.pdf + // + // Usage: + // + // var xor4096 = require('xor4096'); + // random = xor4096(1); // Seed with int32 or string. + // assert.equal(random(), 0.1520436450538547); // (0, 1) range, 53 bits. + // assert.equal(random.int32(), 1806534897); // signed int32, 32 bits. + // + // For nonzero numeric keys, this impelementation provides a sequence + // identical to that by Brent's xorgens 3 implementaion in C. This + // implementation also provides for initalizing the generator with + // string seeds, or for saving and restoring the state of the generator. + // + // On Chrome, this prng benchmarks about 2.1 times slower than + // Javascript's built-in Math.random(). + + (function(global, module, define) { + + function XorGen(seed) { + var me = this; + + // Set up generator function. + me.next = function() { + var w = me.w, + X = me.X, i = me.i, t, v; + // Update Weyl generator. + me.w = w = (w + 0x61c88647) | 0; + // Update xor generator. + v = X[(i + 34) & 127]; + t = X[i = ((i + 1) & 127)]; + v ^= v << 13; + t ^= t << 17; + v ^= v >>> 15; + t ^= t >>> 12; + // Update Xor generator array state. + v = X[i] = v ^ t; + me.i = i; + // Result is the combination. + return (v + (w ^ (w >>> 16))) | 0; + }; + + function init(me, seed) { + var t, v, i, j, w, X = [], limit = 128; + if (seed === (seed | 0)) { + // Numeric seeds initialize v, which is used to generates X. + v = seed; + seed = null; + } else { + // String seeds are mixed into v and X one character at a time. + seed = seed + '\0'; + v = 0; + limit = Math.max(limit, seed.length); + } + // Initialize circular array and weyl value. + for (i = 0, j = -32; j < limit; ++j) { + // Put the unicode characters into the array, and shuffle them. + if (seed) v ^= seed.charCodeAt((j + 32) % seed.length); + // After 32 shuffles, take v as the starting w value. + if (j === 0) w = v; + v ^= v << 10; + v ^= v >>> 15; + v ^= v << 4; + v ^= v >>> 13; + if (j >= 0) { + w = (w + 0x61c88647) | 0; // Weyl. + t = (X[j & 127] ^= (v + w)); // Combine xor and weyl to init array. + i = (0 == t) ? i + 1 : 0; // Count zeroes. + } + } + // We have detected all zeroes; make the key nonzero. + if (i >= 128) { + X[(seed && seed.length || 0) & 127] = -1; + } + // Run the generator 512 times to further mix the state before using it. + // Factoring this as a function slows the main generator, so it is just + // unrolled here. The weyl generator is not advanced while warming up. + i = 127; + for (j = 4 * 128; j > 0; --j) { + v = X[(i + 34) & 127]; + t = X[i = ((i + 1) & 127)]; + v ^= v << 13; + t ^= t << 17; + v ^= v >>> 15; + t ^= t >>> 12; + X[i] = v ^ t; + } + // Storing state as object members is faster than using closure variables. + me.w = w; + me.X = X; + me.i = i; + } + + init(me, seed); + } + + function copy(f, t) { + t.i = f.i; + t.w = f.w; + t.X = f.X.slice(); + return t; + } + function impl(seed, opts) { + if (seed == null) seed = +(new Date); + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (state.X) copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xor4096 = impl; + } + + })( + xor4096, // window object or global + module); + } (xor4096$1)); + return xor4096$1.exports; + } var tychei$1 = {exports: {}}; - tychei$1.exports; - - (function (module) { - // A Javascript implementaion of the "Tyche-i" prng algorithm by - // Samuel Neves and Filipe Araujo. - // See https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - // Set up generator function. - me.next = function() { - var b = me.b, c = me.c, d = me.d, a = me.a; - b = (b << 25) ^ (b >>> 7) ^ c; - c = (c - d) | 0; - d = (d << 24) ^ (d >>> 8) ^ a; - a = (a - b) | 0; - me.b = b = (b << 20) ^ (b >>> 12) ^ c; - me.c = c = (c - d) | 0; - me.d = (d << 16) ^ (c >>> 16) ^ a; - return me.a = (a - b) | 0; - }; - - /* The following is non-inverted tyche, which has better internal - * bit diffusion, but which is about 25% slower than tyche-i in JS. - me.next = function() { - var a = me.a, b = me.b, c = me.c, d = me.d; - a = (me.a + me.b | 0) >>> 0; - d = me.d ^ a; d = d << 16 ^ d >>> 16; - c = me.c + d | 0; - b = me.b ^ c; b = b << 12 ^ d >>> 20; - me.a = a = a + b | 0; - d = d ^ a; me.d = d = d << 8 ^ d >>> 24; - me.c = c = c + d | 0; - b = b ^ c; - return me.b = (b << 7 ^ b >>> 25); - } - */ - - me.a = 0; - me.b = 0; - me.c = 2654435769 | 0; - me.d = 1367130551; - - if (seed === Math.floor(seed)) { - // Integer seed. - me.a = (seed / 0x100000000) | 0; - me.b = seed | 0; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 20; k++) { - me.b ^= strseed.charCodeAt(k) | 0; - me.next(); - } - } - - function copy(f, t) { - t.a = f.a; - t.b = f.b; - t.c = f.c; - t.d = f.d; - return t; - } - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.tychei = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (tychei$1)); - - var tycheiExports = tychei$1.exports; - - var seedrandom$1 = {exports: {}}; + var tychei = tychei$1.exports; + + var hasRequiredTychei; + + function requireTychei () { + if (hasRequiredTychei) return tychei$1.exports; + hasRequiredTychei = 1; + (function (module) { + // A Javascript implementaion of the "Tyche-i" prng algorithm by + // Samuel Neves and Filipe Araujo. + // See https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + // Set up generator function. + me.next = function() { + var b = me.b, c = me.c, d = me.d, a = me.a; + b = (b << 25) ^ (b >>> 7) ^ c; + c = (c - d) | 0; + d = (d << 24) ^ (d >>> 8) ^ a; + a = (a - b) | 0; + me.b = b = (b << 20) ^ (b >>> 12) ^ c; + me.c = c = (c - d) | 0; + me.d = (d << 16) ^ (c >>> 16) ^ a; + return me.a = (a - b) | 0; + }; + + /* The following is non-inverted tyche, which has better internal + * bit diffusion, but which is about 25% slower than tyche-i in JS. + me.next = function() { + var a = me.a, b = me.b, c = me.c, d = me.d; + a = (me.a + me.b | 0) >>> 0; + d = me.d ^ a; d = d << 16 ^ d >>> 16; + c = me.c + d | 0; + b = me.b ^ c; b = b << 12 ^ d >>> 20; + me.a = a = a + b | 0; + d = d ^ a; me.d = d = d << 8 ^ d >>> 24; + me.c = c = c + d | 0; + b = b ^ c; + return me.b = (b << 7 ^ b >>> 25); + } + */ + + me.a = 0; + me.b = 0; + me.c = 2654435769 | 0; + me.d = 1367130551; + + if (seed === Math.floor(seed)) { + // Integer seed. + me.a = (seed / 0x100000000) | 0; + me.b = seed | 0; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 20; k++) { + me.b ^= strseed.charCodeAt(k) | 0; + me.next(); + } + } + + function copy(f, t) { + t.a = f.a; + t.b = f.b; + t.c = f.c; + t.d = f.d; + return t; + } + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.tychei = impl; + } + + })( + tychei, + module); + } (tychei$1)); + return tychei$1.exports; + } + + var seedrandom$2 = {exports: {}}; /* Copyright 2019 David Bau. @@ -5078,300 +5064,315 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - - (function (module) { - (function (global, pool, math) { - // - // The following constants are related to IEEE 754 limits. - // - - var width = 256, // each RC4 output is 0 <= x < 256 - chunks = 6, // at least six RC4 outputs for each double - digits = 52, // there are 52 significant digits in a double - rngname = 'random', // rngname: name for Math.random and Math.seedrandom - startdenom = math.pow(width, chunks), - significance = math.pow(2, digits), - overflow = significance * 2, - mask = width - 1, - nodecrypto; // node.js crypto module, initialized at the bottom. - - // - // seedrandom() - // This is the seedrandom function described above. - // - function seedrandom(seed, options, callback) { - var key = []; - options = (options == true) ? { entropy: true } : (options || {}); - - // Flatten the seed string or build one from local entropy if needed. - var shortseed = mixkey(flatten( - options.entropy ? [seed, tostring(pool)] : - (seed == null) ? autoseed() : seed, 3), key); - - // Use the seed to initialize an ARC4 generator. - var arc4 = new ARC4(key); - - // This function returns a random double in [0, 1) that contains - // randomness in every bit of the mantissa of the IEEE 754 value. - var prng = function() { - var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48 - d = startdenom, // and denominator d = 2 ^ 48. - x = 0; // and no 'extra last byte'. - while (n < significance) { // Fill up all significant digits by - n = (n + x) * width; // shifting numerator and - d *= width; // denominator and generating a - x = arc4.g(1); // new least-significant-byte. - } - while (n >= overflow) { // To avoid rounding up, before adding - n /= 2; // last byte, shift everything - d /= 2; // right using integer math until - x >>>= 1; // we have exactly the desired bits. - } - return (n + x) / d; // Form the number within [0, 1). - }; - - prng.int32 = function() { return arc4.g(4) | 0; }; - prng.quick = function() { return arc4.g(4) / 0x100000000; }; - prng.double = prng; - - // Mix the randomness into accumulated entropy. - mixkey(tostring(arc4.S), pool); - - // Calling convention: what to return as a function of prng, seed, is_math. - return (options.pass || callback || - function(prng, seed, is_math_call, state) { - if (state) { - // Load the arc4 state from the given state if it has an S array. - if (state.S) { copy(state, arc4); } - // Only provide the .state method if requested via options.state. - prng.state = function() { return copy(arc4, {}); }; - } - - // If called as a method of Math (Math.seedrandom()), mutate - // Math.random because that is how seedrandom.js has worked since v1.0. - if (is_math_call) { math[rngname] = prng; return seed; } - - // Otherwise, it is a newer calling convention, so return the - // prng directly. - else return prng; - })( - prng, - shortseed, - 'global' in options ? options.global : (this == math), - options.state); - } - - // - // ARC4 - // - // An ARC4 implementation. The constructor takes a key in the form of - // an array of at most (width) integers that should be 0 <= x < (width). - // - // The g(count) method returns a pseudorandom integer that concatenates - // the next (count) outputs from ARC4. Its return value is a number x - // that is in the range 0 <= x < (width ^ count). - // - function ARC4(key) { - var t, keylen = key.length, - me = this, i = 0, j = me.i = me.j = 0, s = me.S = []; - - // The empty key [] is treated as [0]. - if (!keylen) { key = [keylen++]; } - - // Set up S using the standard key scheduling algorithm. - while (i < width) { - s[i] = i++; - } - for (i = 0; i < width; i++) { - s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))]; - s[j] = t; - } - - // The "g" method returns the next (count) outputs as one number. - (me.g = function(count) { - // Using instance members instead of closure state nearly doubles speed. - var t, r = 0, - i = me.i, j = me.j, s = me.S; - while (count--) { - t = s[i = mask & (i + 1)]; - r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))]; - } - me.i = i; me.j = j; - return r; - // For robust unpredictability, the function call below automatically - // discards an initial batch of values. This is called RC4-drop[256]. - // See http://google.com/search?q=rsa+fluhrer+response&btnI - })(width); - } - - // - // copy() - // Copies internal state of ARC4 to or from a plain object. + var seedrandom$1 = seedrandom$2.exports; + + var hasRequiredSeedrandom$1; + + function requireSeedrandom$1 () { + if (hasRequiredSeedrandom$1) return seedrandom$2.exports; + hasRequiredSeedrandom$1 = 1; + (function (module) { + (function (global, pool, math) { + // + // The following constants are related to IEEE 754 limits. + // + + var width = 256, // each RC4 output is 0 <= x < 256 + chunks = 6, // at least six RC4 outputs for each double + digits = 52, // there are 52 significant digits in a double + rngname = 'random', // rngname: name for Math.random and Math.seedrandom + startdenom = math.pow(width, chunks), + significance = math.pow(2, digits), + overflow = significance * 2, + mask = width - 1, + nodecrypto; // node.js crypto module, initialized at the bottom. + + // + // seedrandom() + // This is the seedrandom function described above. + // + function seedrandom(seed, options, callback) { + var key = []; + options = (options == true) ? { entropy: true } : (options || {}); + + // Flatten the seed string or build one from local entropy if needed. + var shortseed = mixkey(flatten( + options.entropy ? [seed, tostring(pool)] : + (seed == null) ? autoseed() : seed, 3), key); + + // Use the seed to initialize an ARC4 generator. + var arc4 = new ARC4(key); + + // This function returns a random double in [0, 1) that contains + // randomness in every bit of the mantissa of the IEEE 754 value. + var prng = function() { + var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48 + d = startdenom, // and denominator d = 2 ^ 48. + x = 0; // and no 'extra last byte'. + while (n < significance) { // Fill up all significant digits by + n = (n + x) * width; // shifting numerator and + d *= width; // denominator and generating a + x = arc4.g(1); // new least-significant-byte. + } + while (n >= overflow) { // To avoid rounding up, before adding + n /= 2; // last byte, shift everything + d /= 2; // right using integer math until + x >>>= 1; // we have exactly the desired bits. + } + return (n + x) / d; // Form the number within [0, 1). + }; + + prng.int32 = function() { return arc4.g(4) | 0; }; + prng.quick = function() { return arc4.g(4) / 0x100000000; }; + prng.double = prng; + + // Mix the randomness into accumulated entropy. + mixkey(tostring(arc4.S), pool); + + // Calling convention: what to return as a function of prng, seed, is_math. + return (options.pass || callback || + function(prng, seed, is_math_call, state) { + if (state) { + // Load the arc4 state from the given state if it has an S array. + if (state.S) { copy(state, arc4); } + // Only provide the .state method if requested via options.state. + prng.state = function() { return copy(arc4, {}); }; + } + + // If called as a method of Math (Math.seedrandom()), mutate + // Math.random because that is how seedrandom.js has worked since v1.0. + if (is_math_call) { math[rngname] = prng; return seed; } + + // Otherwise, it is a newer calling convention, so return the + // prng directly. + else return prng; + })( + prng, + shortseed, + 'global' in options ? options.global : (this == math), + options.state); + } + + // + // ARC4 + // + // An ARC4 implementation. The constructor takes a key in the form of + // an array of at most (width) integers that should be 0 <= x < (width). + // + // The g(count) method returns a pseudorandom integer that concatenates + // the next (count) outputs from ARC4. Its return value is a number x + // that is in the range 0 <= x < (width ^ count). + // + function ARC4(key) { + var t, keylen = key.length, + me = this, i = 0, j = me.i = me.j = 0, s = me.S = []; + + // The empty key [] is treated as [0]. + if (!keylen) { key = [keylen++]; } + + // Set up S using the standard key scheduling algorithm. + while (i < width) { + s[i] = i++; + } + for (i = 0; i < width; i++) { + s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))]; + s[j] = t; + } + + // The "g" method returns the next (count) outputs as one number. + (me.g = function(count) { + // Using instance members instead of closure state nearly doubles speed. + var t, r = 0, + i = me.i, j = me.j, s = me.S; + while (count--) { + t = s[i = mask & (i + 1)]; + r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))]; + } + me.i = i; me.j = j; + return r; + // For robust unpredictability, the function call below automatically + // discards an initial batch of values. This is called RC4-drop[256]. + // See http://google.com/search?q=rsa+fluhrer+response&btnI + })(width); + } + + // + // copy() + // Copies internal state of ARC4 to or from a plain object. + // + function copy(f, t) { + t.i = f.i; + t.j = f.j; + t.S = f.S.slice(); + return t; + } + // + // flatten() + // Converts an object tree to nested arrays of strings. + // + function flatten(obj, depth) { + var result = [], typ = (typeof obj), prop; + if (depth && typ == 'object') { + for (prop in obj) { + try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {} + } + } + return (result.length ? result : typ == 'string' ? obj : obj + '\0'); + } + + // + // mixkey() + // Mixes a string seed into a key that is an array of integers, and + // returns a shortened string seed that is equivalent to the result key. + // + function mixkey(seed, key) { + var stringseed = seed + '', smear, j = 0; + while (j < stringseed.length) { + key[mask & j] = + mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++)); + } + return tostring(key); + } + + // + // autoseed() + // Returns an object for autoseeding, using window.crypto and Node crypto + // module if available. + // + function autoseed() { + try { + var out; + if (nodecrypto && (out = nodecrypto.randomBytes)) { + // The use of 'out' to remember randomBytes makes tight minified code. + out = out(width); + } else { + out = new Uint8Array(width); + (global.crypto || global.msCrypto).getRandomValues(out); + } + return tostring(out); + } catch (e) { + var browser = global.navigator, + plugins = browser && browser.plugins; + return [+new Date, global, plugins, global.screen, tostring(pool)]; + } + } + + // + // tostring() + // Converts an array of charcodes to a string + // + function tostring(a) { + return String.fromCharCode.apply(0, a); + } + + // + // When seedrandom.js is loaded, we immediately mix a few bits + // from the built-in RNG into the entropy pool. Because we do + // not want to interfere with deterministic PRNG state later, + // seedrandom will not call math.random on its own again after + // initialization. + // + mixkey(math.random(), pool); + + // + // Nodejs and AMD support: export the implementation as a module using + // either convention. + // + if (module.exports) { + module.exports = seedrandom; + // When in node.js, try using crypto package for autoseeding. + try { + nodecrypto = require('crypto'); + } catch (ex) {} + } else { + // When included as a plain script, set up Math.seedrandom global. + math['seed' + rngname] = seedrandom; + } + + + // End anonymous scope, and pass initial values. + })( + // global: `self` in browsers (including strict mode and web workers), + // otherwise `this` in Node and other environments + (typeof self !== 'undefined') ? self : seedrandom$1, + [], // pool: entropy pool starts empty + Math // math: package containing random, pow, and seedrandom + ); + } (seedrandom$2)); + return seedrandom$2.exports; + } + + var seedrandom; + var hasRequiredSeedrandom; + + function requireSeedrandom () { + if (hasRequiredSeedrandom) return seedrandom; + hasRequiredSeedrandom = 1; + // A library of seedable RNGs implemented in Javascript. // - function copy(f, t) { - t.i = f.i; - t.j = f.j; - t.S = f.S.slice(); - return t; - } - // - // flatten() - // Converts an object tree to nested arrays of strings. - // - function flatten(obj, depth) { - var result = [], typ = (typeof obj), prop; - if (depth && typ == 'object') { - for (prop in obj) { - try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {} - } - } - return (result.length ? result : typ == 'string' ? obj : obj + '\0'); - } - - // - // mixkey() - // Mixes a string seed into a key that is an array of integers, and - // returns a shortened string seed that is equivalent to the result key. - // - function mixkey(seed, key) { - var stringseed = seed + '', smear, j = 0; - while (j < stringseed.length) { - key[mask & j] = - mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++)); - } - return tostring(key); - } - - // - // autoseed() - // Returns an object for autoseeding, using window.crypto and Node crypto - // module if available. - // - function autoseed() { - try { - var out; - if (nodecrypto && (out = nodecrypto.randomBytes)) { - // The use of 'out' to remember randomBytes makes tight minified code. - out = out(width); - } else { - out = new Uint8Array(width); - (global.crypto || global.msCrypto).getRandomValues(out); - } - return tostring(out); - } catch (e) { - var browser = global.navigator, - plugins = browser && browser.plugins; - return [+new Date, global, plugins, global.screen, tostring(pool)]; - } - } - - // - // tostring() - // Converts an array of charcodes to a string - // - function tostring(a) { - return String.fromCharCode.apply(0, a); - } - - // - // When seedrandom.js is loaded, we immediately mix a few bits - // from the built-in RNG into the entropy pool. Because we do - // not want to interfere with deterministic PRNG state later, - // seedrandom will not call math.random on its own again after - // initialization. - // - mixkey(math.random(), pool); - - // - // Nodejs and AMD support: export the implementation as a module using - // either convention. + // Usage: // - if (module.exports) { - module.exports = seedrandom; - // When in node.js, try using crypto package for autoseeding. - try { - nodecrypto = require('crypto'); - } catch (ex) {} - } else { - // When included as a plain script, set up Math.seedrandom global. - math['seed' + rngname] = seedrandom; - } - - - // End anonymous scope, and pass initial values. - })( - // global: `self` in browsers (including strict mode and web workers), - // otherwise `this` in Node and other environments - (typeof self !== 'undefined') ? self : commonjsGlobal, - [], // pool: entropy pool starts empty - Math // math: package containing random, pow, and seedrandom - ); - } (seedrandom$1)); - - var seedrandomExports = seedrandom$1.exports; - - // A library of seedable RNGs implemented in Javascript. - // - // Usage: - // - // var seedrandom = require('seedrandom'); - // var random = seedrandom(1); // or any seed. - // var x = random(); // 0 <= x < 1. Every bit is random. - // var x = random.quick(); // 0 <= x < 1. 32 bits of randomness. - - // alea, a 53-bit multiply-with-carry generator by Johannes Baagøe. - // Period: ~2^116 - // Reported to pass all BigCrush tests. - var alea = aleaExports; - - // xor128, a pure xor-shift generator by George Marsaglia. - // Period: 2^128-1. - // Reported to fail: MatrixRank and LinearComp. - var xor128 = xor128Exports; - - // xorwow, George Marsaglia's 160-bit xor-shift combined plus weyl. - // Period: 2^192-2^32 - // Reported to fail: CollisionOver, SimpPoker, and LinearComp. - var xorwow = xorwowExports; - - // xorshift7, by François Panneton and Pierre L'ecuyer, takes - // a different approach: it adds robustness by allowing more shifts - // than Marsaglia's original three. It is a 7-shift generator - // with 256 bits, that passes BigCrush with no systmatic failures. - // Period 2^256-1. - // No systematic BigCrush failures reported. - var xorshift7 = xorshift7Exports; - - // xor4096, by Richard Brent, is a 4096-bit xor-shift with a - // very long period that also adds a Weyl generator. It also passes - // BigCrush with no systematic failures. Its long period may - // be useful if you have many generators and need to avoid - // collisions. - // Period: 2^4128-2^32. - // No systematic BigCrush failures reported. - var xor4096 = xor4096Exports; - - // Tyche-i, by Samuel Neves and Filipe Araujo, is a bit-shifting random - // number generator derived from ChaCha, a modern stream cipher. - // https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf - // Period: ~2^127 - // No systematic BigCrush failures reported. - var tychei = tycheiExports; - - // The original ARC4-based prng included in this library. - // Period: ~2^1600 - var sr = seedrandomExports; - - sr.alea = alea; - sr.xor128 = xor128; - sr.xorwow = xorwow; - sr.xorshift7 = xorshift7; - sr.xor4096 = xor4096; - sr.tychei = tychei; - - var seedrandom = sr; - - var Seedrandom = /*@__PURE__*/getDefaultExportFromCjs(seedrandom); + // var seedrandom = require('seedrandom'); + // var random = seedrandom(1); // or any seed. + // var x = random(); // 0 <= x < 1. Every bit is random. + // var x = random.quick(); // 0 <= x < 1. 32 bits of randomness. + + // alea, a 53-bit multiply-with-carry generator by Johannes Baagøe. + // Period: ~2^116 + // Reported to pass all BigCrush tests. + var alea = requireAlea(); + + // xor128, a pure xor-shift generator by George Marsaglia. + // Period: 2^128-1. + // Reported to fail: MatrixRank and LinearComp. + var xor128 = requireXor128(); + + // xorwow, George Marsaglia's 160-bit xor-shift combined plus weyl. + // Period: 2^192-2^32 + // Reported to fail: CollisionOver, SimpPoker, and LinearComp. + var xorwow = requireXorwow(); + + // xorshift7, by François Panneton and Pierre L'ecuyer, takes + // a different approach: it adds robustness by allowing more shifts + // than Marsaglia's original three. It is a 7-shift generator + // with 256 bits, that passes BigCrush with no systmatic failures. + // Period 2^256-1. + // No systematic BigCrush failures reported. + var xorshift7 = requireXorshift7(); + + // xor4096, by Richard Brent, is a 4096-bit xor-shift with a + // very long period that also adds a Weyl generator. It also passes + // BigCrush with no systematic failures. Its long period may + // be useful if you have many generators and need to avoid + // collisions. + // Period: 2^4128-2^32. + // No systematic BigCrush failures reported. + var xor4096 = requireXor4096(); + + // Tyche-i, by Samuel Neves and Filipe Araujo, is a bit-shifting random + // number generator derived from ChaCha, a modern stream cipher. + // https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf + // Period: ~2^127 + // No systematic BigCrush failures reported. + var tychei = requireTychei(); + + // The original ARC4-based prng included in this library. + // Period: ~2^1600 + var sr = requireSeedrandom$1(); + + sr.alea = alea; + sr.xor128 = xor128; + sr.xorwow = xorwow; + sr.xorshift7 = xorshift7; + sr.xor4096 = xor4096; + sr.tychei = tychei; + + seedrandom = sr; + return seedrandom; + } + + var seedrandomExports = requireSeedrandom(); + var Seedrandom = /*@__PURE__*/getDefaultExportFromCjs(seedrandomExports); /** * @param {HTMLCanvasElement} canvas @@ -5896,7 +5897,7 @@ try { this.defineProperty(globalThis, property, { get: () => value, - // eslint-disable-next-line @typescript-eslint/no-empty-function + set: () => {}, configurable: true, enumerable: true @@ -8401,6 +8402,7 @@ /** * Append both to the shadow root */ + // eslint-disable-next-line @typescript-eslint/no-unused-expressions feedbackLink && this.placeholderBlocked.appendChild(feedbackLink); shadow.appendChild(this.placeholderBlocked); shadow.appendChild(style); @@ -10671,7 +10673,7 @@ }); // Listen to message from Platform letting CTL know that we're ready to // replace elements in the page - // eslint-disable-next-line promise/prefer-await-to-then + this.messaging.subscribe( 'displayClickToLoadPlaceholders', // TODO: Pass `message.options.ruleAction` through, that way only @@ -13036,7 +13038,7 @@ * @internal */ class DuckPlayerFeature extends ContentFeature { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + init (args) { /** * This feature never operates in a frame diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css index 97d77ce608d7..1a5823f3cb6b 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css @@ -413,7 +413,12 @@ body[data-display=app] { /* pages/duckplayer/app/components/Tooltip.module.css */ .Tooltip_tooltip { position: absolute; - background: linear-gradient(0deg, rgba(48, 48, 48, 0.35), rgba(48, 48, 48, 0.35)), rgba(33, 33, 33, 0.55); + background: + linear-gradient( + 0deg, + rgba(48, 48, 48, 0.35), + rgba(48, 48, 48, 0.35)), + rgba(33, 33, 33, 0.55); background-blend-mode: normal, luminosity; box-shadow: inset 0px 0px 1px #ffffff; filter: drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 8px 16px rgba(0, 0, 0, 0.2)); @@ -543,7 +548,12 @@ body[data-display=app] { position: absolute; inset: 0; height: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.48) 32.23%, #000 93.87%); + background: + linear-gradient( + 0deg, + rgba(0, 0, 0, 0.00) 0%, + rgba(0, 0, 0, 0.48) 32.23%, + #000 93.87%); transition: all .3s ease-in-out; } .Background_bg::after { @@ -551,7 +561,12 @@ body[data-display=app] { position: absolute; inset: 0; height: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.90) 34.34%, #000 100%); + background: + linear-gradient( + 0deg, + rgba(0, 0, 0, 0.48) 0%, + rgba(0, 0, 0, 0.90) 34.34%, + #000 100%); opacity: 0; visibility: hidden; transition: all .3s ease-in-out; diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js index df62aec3b196..6eb419c4f485 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js @@ -32,36 +32,45 @@ (function() { "use strict"; var hasOwn = {}.hasOwnProperty; - var nativeCodeString = "[native code]"; function classNames() { - var classes = []; + var classes = ""; for (var i3 = 0; i3 < arguments.length; i3++) { var arg = arguments[i3]; - if (!arg) - continue; - var argType = typeof arg; - if (argType === "string" || argType === "number") { - classes.push(arg); - } else if (Array.isArray(arg)) { - if (arg.length) { - var inner = classNames.apply(null, arg); - if (inner) { - classes.push(inner); - } - } - } else if (argType === "object") { - if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes("[native code]")) { - classes.push(arg.toString()); - continue; - } - for (var key in arg) { - if (hasOwn.call(arg, key) && arg[key]) { - classes.push(key); - } - } + if (arg) { + classes = appendClass(classes, parseValue(arg)); } } - return classes.join(" "); + return classes; + } + function parseValue(arg) { + if (typeof arg === "string" || typeof arg === "number") { + return arg; + } + if (typeof arg !== "object") { + return ""; + } + if (Array.isArray(arg)) { + return classNames.apply(null, arg); + } + if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes("[native code]")) { + return arg.toString(); + } + var classes = ""; + for (var key in arg) { + if (hasOwn.call(arg, key) && arg[key]) { + classes = appendClass(classes, key); + } + } + return classes; + } + function appendClass(value, newClass) { + if (!newClass) { + return value; + } + if (value) { + return value + " " + newClass; + } + return value + newClass; } if (typeof module !== "undefined" && module.exports) { classNames.default = classNames; @@ -122,10 +131,8 @@ return eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.id === msg.id; }; function isMessageResponse(data2) { - if ("result" in data2) - return true; - if ("error" in data2) - return true; + if ("result" in data2) return true; + if ("error" in data2) return true; return false; } return new this.globals.Promise((resolve, reject) => { @@ -186,8 +193,7 @@ return; } if (comparator(event.data)) { - if (!teardown) - throw new Error("unreachable"); + if (!teardown) throw new Error("unreachable"); callback(event.data, teardown); } }; @@ -537,8 +543,7 @@ */ captureWebkitHandlers(handlerNames) { const handlers = window.webkit.messageHandlers; - if (!handlers) - throw new MissingHandler("window.webkit.messageHandlers was absent", "all"); + if (!handlers) throw new MissingHandler("window.webkit.messageHandlers was absent", "all"); for (const webkitMessageHandlerName of handlerNames) { if (typeof handlers[webkitMessageHandlerName]?.postMessage === "function") { const original = handlers[webkitMessageHandlerName]; @@ -763,8 +768,7 @@ * @internal */ _dispatch(payload) { - if (!payload) - return this._log("no response"); + if (!payload) return this._log("no response"); if ("id" in payload) { if (this.listeners.has(payload.id)) { this._tryCatch(() => this.listeners.get(payload.id)?.(payload)); @@ -1020,10 +1024,8 @@ * @returns {Environment} */ withInjectName(injectName) { - if (!injectName) - return this; - if (!isInjectName(injectName)) - return this; + if (!injectName) return this; + if (!isInjectName(injectName)) return this; return new _Environment({ ...this, injectName @@ -1034,10 +1036,8 @@ * @returns {Environment} */ withEnv(env) { - if (!env) - return this; - if (env !== "production" && env !== "development") - return this; + if (!env) return this; + if (env !== "production" && env !== "development") return this; return new _Environment({ ...this, env @@ -1048,10 +1048,8 @@ * @returns {Environment} */ withDisplay(display) { - if (!display) - return this; - if (display !== "app" && display !== "components") - return this; + if (!display) return this; + if (display !== "app" && display !== "components") return this; return new _Environment({ ...this, display @@ -1062,12 +1060,9 @@ * @returns {Environment} */ withLocale(locale) { - if (!locale) - return this; - if (typeof locale !== "string") - return this; - if (locale.length !== 2) - return this; + if (!locale) return this; + if (typeof locale !== "string") return this; + if (locale.length !== 2) return this; return new _Environment({ ...this, locale @@ -1078,8 +1073,7 @@ * @returns {Environment} */ withTextLength(length) { - if (!length) - return this; + if (!length) return this; const num = Number(length); if (num >= 1 && num <= 2) { return new _Environment({ @@ -1145,7 +1139,6 @@ /** * @param {import('@duckduckgo/messaging').RequestMessage} msg */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars request: (msg) => { console.log(msg); if (msg.method === "initialSetup") { @@ -1205,8 +1198,7 @@ var p = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; var y = Array.isArray; function d(n2, l3) { - for (var u3 in l3) - n2[u3] = l3[u3]; + for (var u3 in l3) n2[u3] = l3[u3]; return n2; } function w(n2) { @@ -1214,11 +1206,8 @@ } function _(l3, u3, t3) { var i3, o3, r3, f3 = {}; - for (r3 in u3) - "key" == r3 ? i3 = u3[r3] : "ref" == r3 ? o3 = u3[r3] : f3[r3] = u3[r3]; - if (arguments.length > 2 && (f3.children = arguments.length > 3 ? n.call(arguments, 2) : t3), "function" == typeof l3 && null != l3.defaultProps) - for (r3 in l3.defaultProps) - void 0 === f3[r3] && (f3[r3] = l3.defaultProps[r3]); + for (r3 in u3) "key" == r3 ? i3 = u3[r3] : "ref" == r3 ? o3 = u3[r3] : f3[r3] = u3[r3]; + if (arguments.length > 2 && (f3.children = arguments.length > 3 ? n.call(arguments, 2) : t3), "function" == typeof l3 && null != l3.defaultProps) for (r3 in l3.defaultProps) void 0 === f3[r3] && (f3[r3] = l3.defaultProps[r3]); return g(l3, f3, i3, o3, null); } function g(n2, t3, i3, o3, r3) { @@ -1232,21 +1221,17 @@ this.props = n2, this.context = l3; } function x(n2, l3) { - if (null == l3) - return n2.__ ? x(n2.__, n2.__i + 1) : null; - for (var u3; l3 < n2.__k.length; l3++) - if (null != (u3 = n2.__k[l3]) && null != u3.__e) - return u3.__e; + if (null == l3) return n2.__ ? x(n2.__, n2.__i + 1) : null; + for (var u3; l3 < n2.__k.length; l3++) if (null != (u3 = n2.__k[l3]) && null != u3.__e) return u3.__e; return "function" == typeof n2.type ? x(n2) : null; } function C(n2) { var l3, u3; if (null != (n2 = n2.__) && null != n2.__c) { - for (n2.__e = n2.__c.base = null, l3 = 0; l3 < n2.__k.length; l3++) - if (null != (u3 = n2.__k[l3]) && null != u3.__e) { - n2.__e = n2.__c.base = u3.__e; - break; - } + for (n2.__e = n2.__c.base = null, l3 = 0; l3 < n2.__k.length; l3++) if (null != (u3 = n2.__k[l3]) && null != u3.__e) { + n2.__e = n2.__c.base = u3.__e; + break; + } return C(n2); } } @@ -1255,29 +1240,23 @@ } function M() { var n2, u3, t3, o3, r3, e3, c3, s3; - for (i.sort(f); n2 = i.shift(); ) - n2.__d && (u3 = i.length, o3 = void 0, e3 = (r3 = (t3 = n2).__v).__e, c3 = [], s3 = [], t3.__P && ((o3 = d({}, r3)).__v = r3.__v + 1, l.vnode && l.vnode(o3), O(t3.__P, o3, r3, t3.__n, t3.__P.namespaceURI, 32 & r3.__u ? [e3] : null, c3, null == e3 ? x(r3) : e3, !!(32 & r3.__u), s3), o3.__v = r3.__v, o3.__.__k[o3.__i] = o3, j(c3, o3, s3), o3.__e != e3 && C(o3)), i.length > u3 && i.sort(f)); + for (i.sort(f); n2 = i.shift(); ) n2.__d && (u3 = i.length, o3 = void 0, e3 = (r3 = (t3 = n2).__v).__e, c3 = [], s3 = [], t3.__P && ((o3 = d({}, r3)).__v = r3.__v + 1, l.vnode && l.vnode(o3), O(t3.__P, o3, r3, t3.__n, t3.__P.namespaceURI, 32 & r3.__u ? [e3] : null, c3, null == e3 ? x(r3) : e3, !!(32 & r3.__u), s3), o3.__v = r3.__v, o3.__.__k[o3.__i] = o3, j(c3, o3, s3), o3.__e != e3 && C(o3)), i.length > u3 && i.sort(f)); M.__r = 0; } function P(n2, l3, u3, t3, i3, o3, r3, f3, e3, c3, s3) { var a3, p3, y3, d3, w3, _3 = t3 && t3.__k || v, g3 = l3.length; - for (u3.__d = e3, $(u3, l3, _3), e3 = u3.__d, a3 = 0; a3 < g3; a3++) - null != (y3 = u3.__k[a3]) && (p3 = -1 === y3.__i ? h : _3[y3.__i] || h, y3.__i = a3, O(n2, y3, p3, i3, o3, r3, f3, e3, c3, s3), d3 = y3.__e, y3.ref && p3.ref != y3.ref && (p3.ref && N(p3.ref, null, y3), s3.push(y3.ref, y3.__c || d3, y3)), null == w3 && null != d3 && (w3 = d3), 65536 & y3.__u || p3.__k === y3.__k ? e3 = I(y3, e3, n2) : "function" == typeof y3.type && void 0 !== y3.__d ? e3 = y3.__d : d3 && (e3 = d3.nextSibling), y3.__d = void 0, y3.__u &= -196609); + for (u3.__d = e3, $(u3, l3, _3), e3 = u3.__d, a3 = 0; a3 < g3; a3++) null != (y3 = u3.__k[a3]) && (p3 = -1 === y3.__i ? h : _3[y3.__i] || h, y3.__i = a3, O(n2, y3, p3, i3, o3, r3, f3, e3, c3, s3), d3 = y3.__e, y3.ref && p3.ref != y3.ref && (p3.ref && N(p3.ref, null, y3), s3.push(y3.ref, y3.__c || d3, y3)), null == w3 && null != d3 && (w3 = d3), 65536 & y3.__u || p3.__k === y3.__k ? e3 = I(y3, e3, n2) : "function" == typeof y3.type && void 0 !== y3.__d ? e3 = y3.__d : d3 && (e3 = d3.nextSibling), y3.__d = void 0, y3.__u &= -196609); u3.__d = e3, u3.__e = w3; } function $(n2, l3, u3) { var t3, i3, o3, r3, f3, e3 = l3.length, c3 = u3.length, s3 = c3, a3 = 0; - for (n2.__k = [], t3 = 0; t3 < e3; t3++) - null != (i3 = l3[t3]) && "boolean" != typeof i3 && "function" != typeof i3 ? (r3 = t3 + a3, (i3 = n2.__k[t3] = "string" == typeof i3 || "number" == typeof i3 || "bigint" == typeof i3 || i3.constructor == String ? g(null, i3, null, null, null) : y(i3) ? g(b, { children: i3 }, null, null, null) : void 0 === i3.constructor && i3.__b > 0 ? g(i3.type, i3.props, i3.key, i3.ref ? i3.ref : null, i3.__v) : i3).__ = n2, i3.__b = n2.__b + 1, o3 = null, -1 !== (f3 = i3.__i = L(i3, u3, r3, s3)) && (s3--, (o3 = u3[f3]) && (o3.__u |= 131072)), null == o3 || null === o3.__v ? (-1 == f3 && a3--, "function" != typeof i3.type && (i3.__u |= 65536)) : f3 !== r3 && (f3 == r3 - 1 ? a3-- : f3 == r3 + 1 ? a3++ : (f3 > r3 ? a3-- : a3++, i3.__u |= 65536))) : i3 = n2.__k[t3] = null; - if (s3) - for (t3 = 0; t3 < c3; t3++) - null != (o3 = u3[t3]) && 0 == (131072 & o3.__u) && (o3.__e == n2.__d && (n2.__d = x(o3)), V(o3, o3)); + for (n2.__k = [], t3 = 0; t3 < e3; t3++) null != (i3 = l3[t3]) && "boolean" != typeof i3 && "function" != typeof i3 ? (r3 = t3 + a3, (i3 = n2.__k[t3] = "string" == typeof i3 || "number" == typeof i3 || "bigint" == typeof i3 || i3.constructor == String ? g(null, i3, null, null, null) : y(i3) ? g(b, { children: i3 }, null, null, null) : void 0 === i3.constructor && i3.__b > 0 ? g(i3.type, i3.props, i3.key, i3.ref ? i3.ref : null, i3.__v) : i3).__ = n2, i3.__b = n2.__b + 1, o3 = null, -1 !== (f3 = i3.__i = L(i3, u3, r3, s3)) && (s3--, (o3 = u3[f3]) && (o3.__u |= 131072)), null == o3 || null === o3.__v ? (-1 == f3 && a3--, "function" != typeof i3.type && (i3.__u |= 65536)) : f3 !== r3 && (f3 == r3 - 1 ? a3-- : f3 == r3 + 1 ? a3++ : (f3 > r3 ? a3-- : a3++, i3.__u |= 65536))) : i3 = n2.__k[t3] = null; + if (s3) for (t3 = 0; t3 < c3; t3++) null != (o3 = u3[t3]) && 0 == (131072 & o3.__u) && (o3.__e == n2.__d && (n2.__d = x(o3)), V(o3, o3)); } function I(n2, l3, u3) { var t3, i3; if ("function" == typeof n2.type) { - for (t3 = n2.__k, i3 = 0; t3 && i3 < t3.length; i3++) - t3[i3] && (t3[i3].__ = n2, l3 = I(t3[i3], l3, u3)); + for (t3 = n2.__k, i3 = 0; t3 && i3 < t3.length; i3++) t3[i3] && (t3[i3].__ = n2, l3 = I(t3[i3], l3, u3)); return l3; } n2.__e != l3 && (l3 && n2.type && !u3.contains(l3) && (l3 = x(n2)), u3.insertBefore(n2.__e, l3 || null), l3 = n2.__e); @@ -1288,21 +1267,17 @@ } function L(n2, l3, u3, t3) { var i3 = n2.key, o3 = n2.type, r3 = u3 - 1, f3 = u3 + 1, e3 = l3[u3]; - if (null === e3 || e3 && i3 == e3.key && o3 === e3.type && 0 == (131072 & e3.__u)) - return u3; - if (t3 > (null != e3 && 0 == (131072 & e3.__u) ? 1 : 0)) - for (; r3 >= 0 || f3 < l3.length; ) { - if (r3 >= 0) { - if ((e3 = l3[r3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) - return r3; - r3--; - } - if (f3 < l3.length) { - if ((e3 = l3[f3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) - return f3; - f3++; - } + if (null === e3 || e3 && i3 == e3.key && o3 === e3.type && 0 == (131072 & e3.__u)) return u3; + if (t3 > (null != e3 && 0 == (131072 & e3.__u) ? 1 : 0)) for (; r3 >= 0 || f3 < l3.length; ) { + if (r3 >= 0) { + if ((e3 = l3[r3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) return r3; + r3--; + } + if (f3 < l3.length) { + if ((e3 = l3[f3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) return f3; + f3++; } + } return -1; } function T(n2, l3, u3) { @@ -1310,93 +1285,70 @@ } function A(n2, l3, u3, t3, i3) { var o3; - n: - if ("style" === l3) - if ("string" == typeof u3) - n2.style.cssText = u3; - else { - if ("string" == typeof t3 && (n2.style.cssText = t3 = ""), t3) - for (l3 in t3) - u3 && l3 in u3 || T(n2.style, l3, ""); - if (u3) - for (l3 in u3) - t3 && u3[l3] === t3[l3] || T(n2.style, l3, u3[l3]); - } - else if ("o" === l3[0] && "n" === l3[1]) - o3 = l3 !== (l3 = l3.replace(/(PointerCapture)$|Capture$/i, "$1")), l3 = l3.toLowerCase() in n2 || "onFocusOut" === l3 || "onFocusIn" === l3 ? l3.toLowerCase().slice(2) : l3.slice(2), n2.l || (n2.l = {}), n2.l[l3 + o3] = u3, u3 ? t3 ? u3.u = t3.u : (u3.u = e, n2.addEventListener(l3, o3 ? s : c, o3)) : n2.removeEventListener(l3, o3 ? s : c, o3); - else { - if ("http://www.w3.org/2000/svg" == i3) - l3 = l3.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s"); - else if ("width" != l3 && "height" != l3 && "href" != l3 && "list" != l3 && "form" != l3 && "tabIndex" != l3 && "download" != l3 && "rowSpan" != l3 && "colSpan" != l3 && "role" != l3 && "popover" != l3 && l3 in n2) - try { - n2[l3] = null == u3 ? "" : u3; - break n; - } catch (n3) { - } - "function" == typeof u3 || (null == u3 || false === u3 && "-" !== l3[4] ? n2.removeAttribute(l3) : n2.setAttribute(l3, "popover" == l3 && 1 == u3 ? "" : u3)); + n: if ("style" === l3) if ("string" == typeof u3) n2.style.cssText = u3; + else { + if ("string" == typeof t3 && (n2.style.cssText = t3 = ""), t3) for (l3 in t3) u3 && l3 in u3 || T(n2.style, l3, ""); + if (u3) for (l3 in u3) t3 && u3[l3] === t3[l3] || T(n2.style, l3, u3[l3]); + } + else if ("o" === l3[0] && "n" === l3[1]) o3 = l3 !== (l3 = l3.replace(/(PointerCapture)$|Capture$/i, "$1")), l3 = l3.toLowerCase() in n2 || "onFocusOut" === l3 || "onFocusIn" === l3 ? l3.toLowerCase().slice(2) : l3.slice(2), n2.l || (n2.l = {}), n2.l[l3 + o3] = u3, u3 ? t3 ? u3.u = t3.u : (u3.u = e, n2.addEventListener(l3, o3 ? s : c, o3)) : n2.removeEventListener(l3, o3 ? s : c, o3); + else { + if ("http://www.w3.org/2000/svg" == i3) l3 = l3.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s"); + else if ("width" != l3 && "height" != l3 && "href" != l3 && "list" != l3 && "form" != l3 && "tabIndex" != l3 && "download" != l3 && "rowSpan" != l3 && "colSpan" != l3 && "role" != l3 && "popover" != l3 && l3 in n2) try { + n2[l3] = null == u3 ? "" : u3; + break n; + } catch (n3) { } + "function" == typeof u3 || (null == u3 || false === u3 && "-" !== l3[4] ? n2.removeAttribute(l3) : n2.setAttribute(l3, "popover" == l3 && 1 == u3 ? "" : u3)); + } } function F(n2) { return function(u3) { if (this.l) { var t3 = this.l[u3.type + n2]; - if (null == u3.t) - u3.t = e++; - else if (u3.t < t3.u) - return; + if (null == u3.t) u3.t = e++; + else if (u3.t < t3.u) return; return t3(l.event ? l.event(u3) : u3); } }; } function O(n2, u3, t3, i3, o3, r3, f3, e3, c3, s3) { var a3, h3, v3, p3, w3, _3, g3, m2, x3, C3, S2, M2, $2, I2, H, L2, T3 = u3.type; - if (void 0 !== u3.constructor) - return null; + if (void 0 !== u3.constructor) return null; 128 & t3.__u && (c3 = !!(32 & t3.__u), r3 = [e3 = u3.__e = t3.__e]), (a3 = l.__b) && a3(u3); - n: - if ("function" == typeof T3) - try { - if (m2 = u3.props, x3 = "prototype" in T3 && T3.prototype.render, C3 = (a3 = T3.contextType) && i3[a3.__c], S2 = a3 ? C3 ? C3.props.value : a3.__ : i3, t3.__c ? g3 = (h3 = u3.__c = t3.__c).__ = h3.__E : (x3 ? u3.__c = h3 = new T3(m2, S2) : (u3.__c = h3 = new k(m2, S2), h3.constructor = T3, h3.render = q), C3 && C3.sub(h3), h3.props = m2, h3.state || (h3.state = {}), h3.context = S2, h3.__n = i3, v3 = h3.__d = true, h3.__h = [], h3._sb = []), x3 && null == h3.__s && (h3.__s = h3.state), x3 && null != T3.getDerivedStateFromProps && (h3.__s == h3.state && (h3.__s = d({}, h3.__s)), d(h3.__s, T3.getDerivedStateFromProps(m2, h3.__s))), p3 = h3.props, w3 = h3.state, h3.__v = u3, v3) - x3 && null == T3.getDerivedStateFromProps && null != h3.componentWillMount && h3.componentWillMount(), x3 && null != h3.componentDidMount && h3.__h.push(h3.componentDidMount); - else { - if (x3 && null == T3.getDerivedStateFromProps && m2 !== p3 && null != h3.componentWillReceiveProps && h3.componentWillReceiveProps(m2, S2), !h3.__e && (null != h3.shouldComponentUpdate && false === h3.shouldComponentUpdate(m2, h3.__s, S2) || u3.__v === t3.__v)) { - for (u3.__v !== t3.__v && (h3.props = m2, h3.state = h3.__s, h3.__d = false), u3.__e = t3.__e, u3.__k = t3.__k, u3.__k.some(function(n3) { - n3 && (n3.__ = u3); - }), M2 = 0; M2 < h3._sb.length; M2++) - h3.__h.push(h3._sb[M2]); - h3._sb = [], h3.__h.length && f3.push(h3); - break n; - } - null != h3.componentWillUpdate && h3.componentWillUpdate(m2, h3.__s, S2), x3 && null != h3.componentDidUpdate && h3.__h.push(function() { - h3.componentDidUpdate(p3, w3, _3); - }); - } - if (h3.context = S2, h3.props = m2, h3.__P = n2, h3.__e = false, $2 = l.__r, I2 = 0, x3) { - for (h3.state = h3.__s, h3.__d = false, $2 && $2(u3), a3 = h3.render(h3.props, h3.state, h3.context), H = 0; H < h3._sb.length; H++) - h3.__h.push(h3._sb[H]); - h3._sb = []; - } else - do { - h3.__d = false, $2 && $2(u3), a3 = h3.render(h3.props, h3.state, h3.context), h3.state = h3.__s; - } while (h3.__d && ++I2 < 25); - h3.state = h3.__s, null != h3.getChildContext && (i3 = d(d({}, i3), h3.getChildContext())), x3 && !v3 && null != h3.getSnapshotBeforeUpdate && (_3 = h3.getSnapshotBeforeUpdate(p3, w3)), P(n2, y(L2 = null != a3 && a3.type === b && null == a3.key ? a3.props.children : a3) ? L2 : [L2], u3, t3, i3, o3, r3, f3, e3, c3, s3), h3.base = u3.__e, u3.__u &= -161, h3.__h.length && f3.push(h3), g3 && (h3.__E = h3.__ = null); - } catch (n3) { - if (u3.__v = null, c3 || null != r3) { - for (u3.__u |= c3 ? 160 : 128; e3 && 8 === e3.nodeType && e3.nextSibling; ) - e3 = e3.nextSibling; - r3[r3.indexOf(e3)] = null, u3.__e = e3; - } else - u3.__e = t3.__e, u3.__k = t3.__k; - l.__e(n3, u3, t3); - } - else - null == r3 && u3.__v === t3.__v ? (u3.__k = t3.__k, u3.__e = t3.__e) : u3.__e = z(t3.__e, u3, t3, i3, o3, r3, f3, c3, s3); + n: if ("function" == typeof T3) try { + if (m2 = u3.props, x3 = "prototype" in T3 && T3.prototype.render, C3 = (a3 = T3.contextType) && i3[a3.__c], S2 = a3 ? C3 ? C3.props.value : a3.__ : i3, t3.__c ? g3 = (h3 = u3.__c = t3.__c).__ = h3.__E : (x3 ? u3.__c = h3 = new T3(m2, S2) : (u3.__c = h3 = new k(m2, S2), h3.constructor = T3, h3.render = q), C3 && C3.sub(h3), h3.props = m2, h3.state || (h3.state = {}), h3.context = S2, h3.__n = i3, v3 = h3.__d = true, h3.__h = [], h3._sb = []), x3 && null == h3.__s && (h3.__s = h3.state), x3 && null != T3.getDerivedStateFromProps && (h3.__s == h3.state && (h3.__s = d({}, h3.__s)), d(h3.__s, T3.getDerivedStateFromProps(m2, h3.__s))), p3 = h3.props, w3 = h3.state, h3.__v = u3, v3) x3 && null == T3.getDerivedStateFromProps && null != h3.componentWillMount && h3.componentWillMount(), x3 && null != h3.componentDidMount && h3.__h.push(h3.componentDidMount); + else { + if (x3 && null == T3.getDerivedStateFromProps && m2 !== p3 && null != h3.componentWillReceiveProps && h3.componentWillReceiveProps(m2, S2), !h3.__e && (null != h3.shouldComponentUpdate && false === h3.shouldComponentUpdate(m2, h3.__s, S2) || u3.__v === t3.__v)) { + for (u3.__v !== t3.__v && (h3.props = m2, h3.state = h3.__s, h3.__d = false), u3.__e = t3.__e, u3.__k = t3.__k, u3.__k.some(function(n3) { + n3 && (n3.__ = u3); + }), M2 = 0; M2 < h3._sb.length; M2++) h3.__h.push(h3._sb[M2]); + h3._sb = [], h3.__h.length && f3.push(h3); + break n; + } + null != h3.componentWillUpdate && h3.componentWillUpdate(m2, h3.__s, S2), x3 && null != h3.componentDidUpdate && h3.__h.push(function() { + h3.componentDidUpdate(p3, w3, _3); + }); + } + if (h3.context = S2, h3.props = m2, h3.__P = n2, h3.__e = false, $2 = l.__r, I2 = 0, x3) { + for (h3.state = h3.__s, h3.__d = false, $2 && $2(u3), a3 = h3.render(h3.props, h3.state, h3.context), H = 0; H < h3._sb.length; H++) h3.__h.push(h3._sb[H]); + h3._sb = []; + } else do { + h3.__d = false, $2 && $2(u3), a3 = h3.render(h3.props, h3.state, h3.context), h3.state = h3.__s; + } while (h3.__d && ++I2 < 25); + h3.state = h3.__s, null != h3.getChildContext && (i3 = d(d({}, i3), h3.getChildContext())), x3 && !v3 && null != h3.getSnapshotBeforeUpdate && (_3 = h3.getSnapshotBeforeUpdate(p3, w3)), P(n2, y(L2 = null != a3 && a3.type === b && null == a3.key ? a3.props.children : a3) ? L2 : [L2], u3, t3, i3, o3, r3, f3, e3, c3, s3), h3.base = u3.__e, u3.__u &= -161, h3.__h.length && f3.push(h3), g3 && (h3.__E = h3.__ = null); + } catch (n3) { + if (u3.__v = null, c3 || null != r3) { + for (u3.__u |= c3 ? 160 : 128; e3 && 8 === e3.nodeType && e3.nextSibling; ) e3 = e3.nextSibling; + r3[r3.indexOf(e3)] = null, u3.__e = e3; + } else u3.__e = t3.__e, u3.__k = t3.__k; + l.__e(n3, u3, t3); + } + else null == r3 && u3.__v === t3.__v ? (u3.__k = t3.__k, u3.__e = t3.__e) : u3.__e = z(t3.__e, u3, t3, i3, o3, r3, f3, c3, s3); (a3 = l.diffed) && a3(u3); } function j(n2, u3, t3) { u3.__d = void 0; - for (var i3 = 0; i3 < t3.length; i3++) - N(t3[i3], t3[++i3], t3[++i3]); + for (var i3 = 0; i3 < t3.length; i3++) N(t3[i3], t3[++i3], t3[++i3]); l.__c && l.__c(u3, n2), n2.some(function(u4) { try { n2 = u4.__h, u4.__h = [], n2.some(function(n3) { @@ -1410,40 +1362,27 @@ function z(u3, t3, i3, o3, r3, f3, e3, c3, s3) { var a3, v3, p3, d3, _3, g3, m2, b2 = i3.props, k3 = t3.props, C3 = t3.type; if ("svg" === C3 ? r3 = "http://www.w3.org/2000/svg" : "math" === C3 ? r3 = "http://www.w3.org/1998/Math/MathML" : r3 || (r3 = "http://www.w3.org/1999/xhtml"), null != f3) { - for (a3 = 0; a3 < f3.length; a3++) - if ((_3 = f3[a3]) && "setAttribute" in _3 == !!C3 && (C3 ? _3.localName === C3 : 3 === _3.nodeType)) { - u3 = _3, f3[a3] = null; - break; - } + for (a3 = 0; a3 < f3.length; a3++) if ((_3 = f3[a3]) && "setAttribute" in _3 == !!C3 && (C3 ? _3.localName === C3 : 3 === _3.nodeType)) { + u3 = _3, f3[a3] = null; + break; + } } if (null == u3) { - if (null === C3) - return document.createTextNode(k3); + if (null === C3) return document.createTextNode(k3); u3 = document.createElementNS(r3, C3, k3.is && k3), c3 && (l.__m && l.__m(t3, f3), c3 = false), f3 = null; } - if (null === C3) - b2 === k3 || c3 && u3.data === k3 || (u3.data = k3); + if (null === C3) b2 === k3 || c3 && u3.data === k3 || (u3.data = k3); else { - if (f3 = f3 && n.call(u3.childNodes), b2 = i3.props || h, !c3 && null != f3) - for (b2 = {}, a3 = 0; a3 < u3.attributes.length; a3++) - b2[(_3 = u3.attributes[a3]).name] = _3.value; - for (a3 in b2) - if (_3 = b2[a3], "children" == a3) - ; - else if ("dangerouslySetInnerHTML" == a3) - p3 = _3; - else if (!(a3 in k3)) { - if ("value" == a3 && "defaultValue" in k3 || "checked" == a3 && "defaultChecked" in k3) - continue; - A(u3, a3, null, _3, r3); - } - for (a3 in k3) - _3 = k3[a3], "children" == a3 ? d3 = _3 : "dangerouslySetInnerHTML" == a3 ? v3 = _3 : "value" == a3 ? g3 = _3 : "checked" == a3 ? m2 = _3 : c3 && "function" != typeof _3 || b2[a3] === _3 || A(u3, a3, _3, b2[a3], r3); - if (v3) - c3 || p3 && (v3.__html === p3.__html || v3.__html === u3.innerHTML) || (u3.innerHTML = v3.__html), t3.__k = []; - else if (p3 && (u3.innerHTML = ""), P(u3, y(d3) ? d3 : [d3], t3, i3, o3, "foreignObject" === C3 ? "http://www.w3.org/1999/xhtml" : r3, f3, e3, f3 ? f3[0] : i3.__k && x(i3, 0), c3, s3), null != f3) - for (a3 = f3.length; a3--; ) - w(f3[a3]); + if (f3 = f3 && n.call(u3.childNodes), b2 = i3.props || h, !c3 && null != f3) for (b2 = {}, a3 = 0; a3 < u3.attributes.length; a3++) b2[(_3 = u3.attributes[a3]).name] = _3.value; + for (a3 in b2) if (_3 = b2[a3], "children" == a3) ; + else if ("dangerouslySetInnerHTML" == a3) p3 = _3; + else if (!(a3 in k3)) { + if ("value" == a3 && "defaultValue" in k3 || "checked" == a3 && "defaultChecked" in k3) continue; + A(u3, a3, null, _3, r3); + } + for (a3 in k3) _3 = k3[a3], "children" == a3 ? d3 = _3 : "dangerouslySetInnerHTML" == a3 ? v3 = _3 : "value" == a3 ? g3 = _3 : "checked" == a3 ? m2 = _3 : c3 && "function" != typeof _3 || b2[a3] === _3 || A(u3, a3, _3, b2[a3], r3); + if (v3) c3 || p3 && (v3.__html === p3.__html || v3.__html === u3.innerHTML) || (u3.innerHTML = v3.__html), t3.__k = []; + else if (p3 && (u3.innerHTML = ""), P(u3, y(d3) ? d3 : [d3], t3, i3, o3, "foreignObject" === C3 ? "http://www.w3.org/1999/xhtml" : r3, f3, e3, f3 ? f3[0] : i3.__k && x(i3, 0), c3, s3), null != f3) for (a3 = f3.length; a3--; ) w(f3[a3]); c3 || (a3 = "value", "progress" === C3 && null == g3 ? u3.removeAttribute("value") : void 0 !== g3 && (g3 !== u3[a3] || "progress" === C3 && !g3 || "option" === C3 && g3 !== b2[a3]) && A(u3, a3, g3, b2[a3], r3), a3 = "checked", void 0 !== m2 && m2 !== u3[a3] && A(u3, a3, m2, b2[a3], r3)); } return u3; @@ -1453,8 +1392,7 @@ if ("function" == typeof n2) { var i3 = "function" == typeof n2.__u; i3 && n2.__u(), i3 && null == u3 || (n2.__u = n2(u3)); - } else - n2.current = u3; + } else n2.current = u3; } catch (n3) { l.__e(n3, t3); } @@ -1462,17 +1400,14 @@ function V(n2, u3, t3) { var i3, o3; if (l.unmount && l.unmount(n2), (i3 = n2.ref) && (i3.current && i3.current !== n2.__e || N(i3, null, u3)), null != (i3 = n2.__c)) { - if (i3.componentWillUnmount) - try { - i3.componentWillUnmount(); - } catch (n3) { - l.__e(n3, u3); - } + if (i3.componentWillUnmount) try { + i3.componentWillUnmount(); + } catch (n3) { + l.__e(n3, u3); + } i3.base = i3.__P = null; } - if (i3 = n2.__k) - for (o3 = 0; o3 < i3.length; o3++) - i3[o3] && V(i3[o3], u3, t3 || "function" != typeof n2.type); + if (i3 = n2.__k) for (o3 = 0; o3 < i3.length; o3++) i3[o3] && V(i3[o3], u3, t3 || "function" != typeof n2.type); t3 || w(n2.__e), n2.__c = n2.__ = n2.__e = n2.__d = void 0; } function q(n2, l3, u3) { @@ -1506,14 +1441,11 @@ return u3.Provider.__ = u3.Consumer.contextType = u3; } n = v.slice, l = { __e: function(n2, l3, u3, t3) { - for (var i3, o3, r3; l3 = l3.__; ) - if ((i3 = l3.__c) && !i3.__) - try { - if ((o3 = i3.constructor) && null != o3.getDerivedStateFromError && (i3.setState(o3.getDerivedStateFromError(n2)), r3 = i3.__d), null != i3.componentDidCatch && (i3.componentDidCatch(n2, t3 || {}), r3 = i3.__d), r3) - return i3.__E = i3; - } catch (l4) { - n2 = l4; - } + for (var i3, o3, r3; l3 = l3.__; ) if ((i3 = l3.__c) && !i3.__) try { + if ((o3 = i3.constructor) && null != o3.getDerivedStateFromError && (i3.setState(o3.getDerivedStateFromError(n2)), r3 = i3.__d), null != i3.componentDidCatch && (i3.componentDidCatch(n2, t3 || {}), r3 = i3.__d), r3) return i3.__E = i3; + } catch (l4) { + n2 = l4; + } throw n2; } }, u = 0, t = function(n2) { return null != n2 && null == n2.constructor; @@ -1555,15 +1487,13 @@ t3 !== r3 && (o3.__N = [r3, o3.__[1]], o3.__c.setState({})); }], o3.__c = r2, !r2.u)) { var f3 = function(n3, t3, r3) { - if (!o3.__c.__H) - return true; + if (!o3.__c.__H) return true; var u4 = o3.__c.__H.__.filter(function(n4) { return !!n4.__c; }); if (u4.every(function(n4) { return !n4.__N; - })) - return !c3 || c3.call(this, n3, t3, r3); + })) return !c3 || c3.call(this, n3, t3, r3); var i4 = false; return u4.forEach(function(n4) { if (n4.__N) { @@ -1613,21 +1543,18 @@ function g2() { var n2 = d2(t2++, 11); if (!n2.__) { - for (var u3 = r2.__v; null !== u3 && !u3.__m && null !== u3.__; ) - u3 = u3.__; + for (var u3 = r2.__v; null !== u3 && !u3.__m && null !== u3.__; ) u3 = u3.__; var i3 = u3.__m || (u3.__m = [0, 0]); n2.__ = "P" + i3[0] + "-" + i3[1]++; } return n2.__; } function j2() { - for (var n2; n2 = f2.shift(); ) - if (n2.__P && n2.__H) - try { - n2.__H.__h.forEach(z2), n2.__H.__h.forEach(B2), n2.__H.__h = []; - } catch (t3) { - n2.__H.__h = [], c2.__e(t3, n2.__v); - } + for (var n2; n2 = f2.shift(); ) if (n2.__P && n2.__H) try { + n2.__H.__h.forEach(z2), n2.__H.__h.forEach(B2), n2.__H.__h = []; + } catch (t3) { + n2.__H.__h = [], c2.__e(t3, n2.__v); + } } c2.__b = function(n2) { r2 = null, e2 && e2(n2); @@ -1769,13 +1696,11 @@ // shared/translations.js function apply(subject, replacements, textLength = 1) { - if (typeof subject !== "string" || subject.length === 0) - return ""; + if (typeof subject !== "string" || subject.length === 0) return ""; let out = subject; if (replacements) { for (let [name, value] of Object.entries(replacements)) { - if (typeof value !== "string") - value = ""; + if (typeof value !== "string") value = ""; out = out.replaceAll(`{${name}}`, value); } } @@ -1853,8 +1778,7 @@ * @return {EmbedSettings} */ withAutoplay(autoplay) { - if (typeof autoplay !== "boolean") - return this; + if (typeof autoplay !== "boolean") return this; return new _EmbedSettings({ ...this, autoplay @@ -1865,8 +1789,7 @@ * @return {EmbedSettings} */ withMuted(muted) { - if (typeof muted !== "boolean") - return this; + if (typeof muted !== "boolean") return this; return new _EmbedSettings({ ...this, muted @@ -1925,11 +1848,9 @@ * @throws {Error} */ constructor(input) { - if (typeof input !== "string") - throw new Error("string required, got: " + input); + if (typeof input !== "string") throw new Error("string required, got: " + input); const sanitized = sanitizeYoutubeId(input); - if (sanitized === null) - throw new Error("invalid ID from: " + input); + if (sanitized === null) throw new Error("invalid ID from: " + input); this.id = sanitized; } /** @@ -1945,11 +1866,9 @@ * @throws {Error} */ constructor(input) { - if (typeof input !== "string") - throw new Error("string required for timestamp"); + if (typeof input !== "string") throw new Error("string required for timestamp"); const seconds = timestampInSeconds(input); - if (seconds === null) - throw new Error("invalid input for timestamp: " + input); + if (seconds === null) throw new Error("invalid input for timestamp: " + input); this.seconds = seconds; } /** @@ -1957,8 +1876,7 @@ * @return {Timestamp|null} */ static fromHref(href) { - if (typeof href !== "string") - return null; + if (typeof href !== "string") return null; const param = timestampFromHref(href); if (param) { try { @@ -1971,8 +1889,7 @@ } }; function idFromHref(href) { - if (typeof href !== "string") - return null; + if (typeof href !== "string") return null; let url; try { url = new URL(href); @@ -1980,8 +1897,7 @@ return null; } const fromParam = url.searchParams.get("videoID"); - if (fromParam) - return fromParam; + if (fromParam) return fromParam; if (url.protocol === "duck:") { return url.pathname.slice(1); } @@ -1991,8 +1907,7 @@ return null; } function timestampFromHref(href) { - if (typeof href !== "string") - return null; + if (typeof href !== "string") return null; let url; try { url = new URL(href); @@ -2014,8 +1929,7 @@ }; const parts = timestamp.split(/(\d+[hms]?)/); const totalSeconds = parts.reduce((total, part) => { - if (!part) - return total; + if (!part) return total; for (const unit in units) { if (part.includes(unit)) { return total + parseInt(part) * units[unit]; @@ -2103,8 +2017,7 @@ * @return {Settings} */ withFeatureState(named, settings) { - if (!settings) - return this; + if (!settings) return this; const valid = ["pip", "autoplay", "focusMode"]; if (!valid.includes(named)) { console.warn(`Excluding invalid feature key ${named}`); @@ -2249,8 +2162,7 @@ function useOpenOnYoutubeHandler() { const settings = x2(SettingsContext).settings; return (embed) => { - if (!embed) - return console.warn("unreachable, settings.embed must be present"); + if (!embed) return console.warn("unreachable, settings.embed must be present"); try { const base = new URL(settings.youtubeBase); window.location.href = embed.intoYoutubeUrl(base); @@ -2707,8 +2619,7 @@ off(); }); window.addEventListener(EVENT_ON, () => { - if (enabled === true) - return; + if (enabled === true) return; enabled = true; on(); }); @@ -2752,8 +2663,7 @@ setFocusMode("enabled"); } _2(() => { - if (!tooltipRef.current) - return; + if (!tooltipRef.current) return; const icon = tooltipRef.current; const rect = icon.getBoundingClientRect(); const iconTop = rect.top + window.scrollY; @@ -2811,8 +2721,7 @@ formfactor: "desktop", buttonProps: { onClick: () => { - if (embed) - openOnYoutube(embed); + if (embed) openOnYoutube(embed); } } }, @@ -2904,10 +2813,8 @@ let attempt = 0; let id; function check() { - if (!iframe.contentDocument) - return; - if (attempt > maxAttempts) - return; + if (!iframe.contentDocument) return; + if (attempt > maxAttempts) return; attempt += 1; const selector = "#player video"; const video = ( @@ -3019,11 +2926,9 @@ // pages/duckplayer/src/js/utils.js function createYoutubeURLForError(href, urlBase) { const valid = VideoParams.forWatchPage(href); - if (!valid) - return null; + if (!valid) return null; const original = new URL(href); - if (original.searchParams.get("feature") !== "emb_err_woyt") - return null; + if (original.searchParams.get("feature") !== "emb_err_woyt") return null; const url = new URL(urlBase); url.searchParams.set("v", valid.id); if (typeof valid.time === "string") { @@ -3032,10 +2937,8 @@ return url.toString(); } function getValidVideoTitle(iframeTitle) { - if (typeof iframeTitle !== "string") - return null; - if (iframeTitle === "YouTube") - return null; + if (typeof iframeTitle !== "string") return null; + if (iframeTitle === "YouTube") return null; return iframeTitle.replace(/ - YouTube$/g, ""); } @@ -3053,17 +2956,14 @@ */ iframeDidLoad(iframe) { const handler = (e3) => { - if (!e3.target) - return; + if (!e3.target) return; const target = ( /** @type {Element} */ e3.target ); - if (!("href" in target) || typeof target.href !== "string") - return; + if (!("href" in target) || typeof target.href !== "string") return; const next = createYoutubeURLForError(target.href, this.baseUrl); - if (!next) - return; + if (!next) return; e3.preventDefault(); e3.stopImmediatePropagation(); window.location.href = next; @@ -3135,7 +3035,6 @@ * @param {HTMLIFrameElement} iframe * @returns {(() => void) | null} */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars iframeDidLoad(iframe) { return () => { console.log("teardown"); @@ -3240,8 +3139,7 @@ ); const settings = useSettings(); y2(() => { - if (!ref.current) - return; + if (!ref.current) return; const iframe = ref.current; const features = createIframeFeatures(settings); const iframeFeatures = [ @@ -3283,8 +3181,7 @@ }); let embed = EmbedSettings.fromHref("https://localhost?videoID=123"); let url = embed?.toEmbedUrl(); - if (!url) - throw new Error("unreachable"); + if (!url) throw new Error("unreachable"); return /* @__PURE__ */ _(b, null, /* @__PURE__ */ _("div", { "data-layout": "mobile" }, /* @__PURE__ */ _(Background, null)), /* @__PURE__ */ _("main", { class: Components_default.main }, /* @__PURE__ */ _("div", { class: Components_default.tube }, /* @__PURE__ */ _(Wordmark, null), /* @__PURE__ */ _("h2", null, "Floating Bar"), /* @__PURE__ */ _("div", { style: "position: relative; padding-left: 10em; min-height: 150px;" }, /* @__PURE__ */ _(InfoIcon, { debugStyles: true })), /* @__PURE__ */ _("h2", null, "Info Tooltip"), /* @__PURE__ */ _(FloatingBar, null, /* @__PURE__ */ _(Button, { icon: true }, /* @__PURE__ */ _(Icon, { src: info_data_default })), /* @__PURE__ */ _(Button, { icon: true }, /* @__PURE__ */ _(Icon, { src: cog_data_default })), /* @__PURE__ */ _(Button, { fill: true }, "Open in YouTube")), /* @__PURE__ */ _("h2", null, "Info Bar"), /* @__PURE__ */ _(SettingsProvider, { settings }, /* @__PURE__ */ _(SwitchProvider, null, /* @__PURE__ */ _(InfoBar, { embed }))), /* @__PURE__ */ _("br", null), /* @__PURE__ */ _("h2", null, "Mobile Switch Bar (ios)"), /* @__PURE__ */ _(SwitchProvider, null, /* @__PURE__ */ _(SwitchBarMobile, { platformName: "ios" })), /* @__PURE__ */ _("h2", null, "Mobile Switch Bar (android)"), /* @__PURE__ */ _(SwitchProvider, null, /* @__PURE__ */ _(SwitchBarMobile, { platformName: "android" })), /* @__PURE__ */ _("h2", null, "Desktop Switch bar"), /* @__PURE__ */ _("h3", null, "idle"), /* @__PURE__ */ _(SwitchProvider, null, /* @__PURE__ */ _(SwitchBarDesktop, null))), /* @__PURE__ */ _("h2", null, /* @__PURE__ */ _("code", null, "inset=false (desktop)")), /* @__PURE__ */ _(SettingsProvider, { settings }, /* @__PURE__ */ _(PlayerContainer, null, /* @__PURE__ */ _(Player, { src: url, layout: "desktop" }), /* @__PURE__ */ _(InfoBarContainer, null, /* @__PURE__ */ _(InfoBar, { embed })))), /* @__PURE__ */ _("br", null), /* @__PURE__ */ _("h2", null, /* @__PURE__ */ _("code", null, "inset=true (mobile)")), /* @__PURE__ */ _(PlayerContainer, { inset: true }, /* @__PURE__ */ _(PlayerInternal, { inset: true }, /* @__PURE__ */ _(PlayerError, { layout: "mobile", kind: "invalid-id" }), /* @__PURE__ */ _(SwitchBarMobile, { platformName: "ios" }))), /* @__PURE__ */ _("br", null))); } @@ -3353,8 +3250,7 @@ fill: true, buttonProps: { onClick: () => { - if (embed) - openOnYoutube(embed); + if (embed) openOnYoutube(embed); } } }, @@ -3463,8 +3359,7 @@ }; document.body.dataset.layout = settings.layout; const root = document.querySelector("body"); - if (!root) - throw new Error("could not render, root element missing"); + if (!root) throw new Error("could not render, root element missing"); if (environment.display === "app") { B( /* @__PURE__ */ _( @@ -3487,8 +3382,7 @@ } function createEmbedSettings(href, settings) { const embed = EmbedSettings.fromHref(href); - if (!embed) - return null; + if (!embed) return null; return embed.withAutoplay(settings.autoplay.state === "enabled").withMuted(settings.platform.name === "ios"); } async function getTranslationsFromStringOrLoadDynamically(stringInput, locale) { @@ -3675,8 +3569,7 @@ * ``` */ landscapeImpression() { - if (this.oneTimeEvents.has("landscapeImpression")) - return; + if (this.oneTimeEvents.has("landscapeImpression")) return; this.oneTimeEvents.add("landscapeImpression"); this._event({ attributes: { name: "impression", value: "landscape-layout" } }); } diff --git a/package-lock.json b/package-lock.json index bf1ff9daef9c..5fa8d5c8bc53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^10.17.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#15.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.28.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.29.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" }, @@ -54,11 +54,10 @@ }, "node_modules/@duckduckgo/autofill": { "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#c992041d16ec10d790e6204dce9abf9966d1363c", - "hasInstallScript": true, - "license": "Apache-2.0" + "hasInstallScript": true }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#48fee2508995d4ac02d18b3d55424adedcb4ce4f", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#6cab7bdb584653a5dc007cc1ae827ec41c5a91bc", "workspaces": [ "injected", "special-pages", @@ -190,9 +189,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.8.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.6.tgz", - "integrity": "sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==", + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", "dev": true, "dependencies": { "undici-types": "~6.19.8" @@ -536,16 +535,16 @@ } }, "node_modules/tldts-core": { - "version": "6.1.57", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.57.tgz", - "integrity": "sha512-lXnRhuQpx3zU9EONF9F7HfcRLvN1uRYUBIiKL+C/gehC/77XTU+Jye6ui86GA3rU6FjlJ0triD1Tkjt2F/2lEg==" + "version": "6.1.58", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.58.tgz", + "integrity": "sha512-dR936xmhBm7AeqHIhCWwK765gZ7dFyL+IqLSFAjJbFlUXGMLCb8i2PzlzaOuWBuplBTaBYseSb565nk/ZEM0Bg==" }, "node_modules/tldts-experimental": { - "version": "6.1.57", - "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.57.tgz", - "integrity": "sha512-Buk4fIUZF7ehTnXyTTYxa1fCwTT3V0LoUsBfvEziQ9mEsdrAQmdb1fZqrSehT7S7Eb4k1iburh3ShqNpVtSCVQ==", + "version": "6.1.58", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.58.tgz", + "integrity": "sha512-oMXXM56JFUjwcw+2Vt7NP3LQUpK3ZLdGAqSAFwGtAPWjvKK36bJ162UjsnSdFsq6nU3Wae5HYlE8N/vULPZ00g==", "dependencies": { - "tldts-core": "^6.1.57" + "tldts-core": "^6.1.58" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index d37157c221cd..2b1652f0e6f0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^10.17.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#15.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.28.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.29.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" } From 10eb1399d9a6a06aeffc5fdcc12bda65703532e7 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Thu, 7 Nov 2024 10:33:04 +0100 Subject: [PATCH 02/26] Android: Send Privacy Pro Feedback to Support Inbox (#5227) Task/Issue URL: https://app.asana.com/0/1200019156869587/1207815400172115/f ### Description See more information in https://app.asana.com/0/1200019156869587/1207962930855838/f ### Steps to test this PR https://app.asana.com/0/1200019156869587/1208675181927862/f --- .../subscriptions/impl/RealSubscriptions.kt | 4 + .../FeedbackCustomMetadataProvider.kt | 29 +++-- .../feedback/SubscriptionFeedbackActivity.kt | 11 +- .../SubscriptionFeedbackSubmitFragment.kt | 62 +++++++-- .../feedback/SubscriptionFeedbackViewModel.kt | 60 +++++++-- .../impl/feedback/SubscriptionSupportInbox.kt | 84 +++++++++++++ .../impl/services/SubscriptionsService.kt | 22 ++++ .../res/layout/content_feedback_submit.xml | 27 ++-- .../src/main/res/values/donottranslate.xml | 7 +- .../feedback/FakeCustomMetadataProvider.kt | 41 ++++++ .../feedback/FakeSubscriptionSupportInbox.kt | 53 ++++++++ .../SubscriptionFeedbackViewModelTest.kt | 118 ++++++++++++++++-- 12 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionSupportInbox.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeCustomMetadataProvider.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeSubscriptionSupportInbox.kt diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 1bd88fc3e926..ccb3c86c207b 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -132,6 +132,10 @@ interface PrivacyProFeature { @Toggle.DefaultValue(false) fun useUnifiedFeedback(): Toggle + + // Kill switch + @Toggle.DefaultValue(true) + fun allowEmailFeedback(): Toggle } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackCustomMetadataProvider.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackCustomMetadataProvider.kt index 94f02e402dd1..cf306f6353a7 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackCustomMetadataProvider.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackCustomMetadataProvider.kt @@ -34,6 +34,11 @@ interface FeedbackCustomMetadataProvider { category: SubscriptionFeedbackCategory, appPackageId: String? = null, ): String + + suspend fun getCustomMetadataEncoded( + category: SubscriptionFeedbackCategory, + appPackageId: String? = null, + ): String } @ContributesBinding(ActivityScope::class) @@ -48,17 +53,25 @@ class RealFeedbackCustomMetadataProvider @Inject constructor( ): String { return withContext(dispatcherProvider.io()) { when (category) { - VPN -> Base64.encodeToString( - generateVPNCustomMetadata(appPackageId).toByteArray(), - Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE, - ) + VPN -> generateVPNCustomMetadata(appPackageId) + SUBS_AND_PAYMENTS -> generateSubscriptionCustomMetadata() + else -> "" + } + } + } - SUBS_AND_PAYMENTS -> Base64.encodeToString( - generateSubscriptionCustomMetadata().toByteArray(), + override suspend fun getCustomMetadataEncoded( + category: SubscriptionFeedbackCategory, + appPackageId: String?, + ): String { + return getCustomMetadata(category, appPackageId).run { + if (this.isNotEmpty()) { + Base64.encodeToString( + this.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE, ) - - else -> "" + } else { + "" } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackActivity.kt index 0c5c48ddaebe..c5c74c65070f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackActivity.kt @@ -41,6 +41,7 @@ import com.duckduckgo.subscriptions.impl.databinding.ActivityFeedbackBinding import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCancelled import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCompleted +import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackFailed import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.ShowHelpPages import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackFragmentState import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackMetadata @@ -128,8 +129,11 @@ class SubscriptionFeedbackActivity : viewModel.onSubcategorySelected(subCategory) } - override fun onUserSubmit(description: String) { - viewModel.onSubmitFeedback(description) + override fun onUserSubmit( + description: String, + email: String?, + ) { + viewModel.onSubmitFeedback(description, email) } override fun onFaqsOpened() { @@ -151,6 +155,9 @@ class SubscriptionFeedbackActivity : private fun handleCommands(command: Command) { when (command) { + is FeedbackFailed -> + Toast.makeText(applicationContext, R.string.feedbackSubmitFailedMessage, Toast.LENGTH_LONG).show() + is FeedbackCancelled -> finish() is FeedbackCompleted -> { Toast.makeText(applicationContext, R.string.feedbackSubmitCompletedMessage, Toast.LENGTH_LONG).show() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackSubmitFragment.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackSubmitFragment.kt index 4954bc233db3..4c1bd7b50ccf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackSubmitFragment.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackSubmitFragment.kt @@ -18,8 +18,10 @@ package com.duckduckgo.subscriptions.impl.feedback import android.os.Bundle import android.text.Annotation +import android.text.Editable import android.text.SpannableString import android.text.Spanned +import android.text.TextWatcher import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan @@ -34,23 +36,48 @@ import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.duckduckgo.subscriptions.impl.R import com.duckduckgo.subscriptions.impl.databinding.ContentFeedbackSubmitBinding import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackReportType.GENERAL_FEEDBACK import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackReportType.REPORT_PROBLEM -import com.duckduckgo.subscriptions.impl.feedback.pixels.PrivacyProUnifiedFeedbackPixelSender import javax.inject.Inject @InjectWith(FragmentScope::class) class SubscriptionFeedbackSubmitFragment : SubscriptionFeedbackFragment(R.layout.content_feedback_submit) { - private val binding: ContentFeedbackSubmitBinding by viewBinding() @Inject lateinit var globalActivityStarter: GlobalActivityStarter @Inject - lateinit var pixelSender: PrivacyProUnifiedFeedbackPixelSender + lateinit var privacyProFeature: PrivacyProFeature + + private val submitTextWatcher: TextWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int, + ) { + } + + override fun onTextChanged( + s: CharSequence, + start: Int, + before: Int, + count: Int, + ) { + // get the content of both the edit text + val description = binding.feedbackSubmitDescription.text.trim() + + // check whether both the fields are empty or not + binding.feedbackSubmitButton.isEnabled = description.isNotEmpty() + } + + override fun afterTextChanged(s: Editable) { + } + } override fun onViewCreated( view: View, @@ -59,6 +86,7 @@ class SubscriptionFeedbackSubmitFragment : SubscriptionFeedbackFragment(R.layout super.onViewCreated(view, savedInstanceState) val listener = activity as Listener val reportType = requireArguments().getSerializable(EXTRA_REPORT_TYPE) as SubscriptionFeedbackReportType + binding.feedbackSubmitDescription.addTextChangedListener(submitTextWatcher) if (reportType == REPORT_PROBLEM) { binding.feedbackSubmitHeader.show() @@ -69,27 +97,41 @@ class SubscriptionFeedbackSubmitFragment : SubscriptionFeedbackFragment(R.layout ) { listener.onFaqsOpened() } - binding.feedbackSubmitDescriptionHeader.primaryText = getString(R.string.feedbackSubmitVpnDescriptionHeader).uppercase() binding.feedbackSubmitDescription.hint = getString(R.string.feedbackSubmitVpnDescriptionHint) + + if (privacyProFeature.allowEmailFeedback().isEnabled()) { + binding.showEmail() + } else { + binding.hideEmail() + } } else { binding.feedbackSubmitHeader.gone() binding.feedbackSubmitByLine.gone() + binding.hideEmail() if (reportType == GENERAL_FEEDBACK) { - binding.feedbackSubmitDescriptionHeader.primaryText = getString(R.string.feedbackActionGeneralFeedback).uppercase() binding.feedbackSubmitDescription.hint = getString(R.string.feedbackSubmitGeneralDescriptionHint) } else { - binding.feedbackSubmitDescriptionHeader.primaryText = getString(R.string.feedbackActionFeatureRequest).uppercase() binding.feedbackSubmitDescription.hint = getString(R.string.feedbackSubmitFeatureRequestDescriptionHint) } } binding.feedbackSubmitButton.setOnClickListener { - listener.onUserSubmit(binding.feedbackSubmitDescription.text) + listener.onUserSubmit(binding.feedbackSubmitDescription.text.trim(), binding.feedbackSubmitEmail.text.trim()) } binding.feedbackSubmitDescription.showKeyboard() } + private fun ContentFeedbackSubmitBinding.showEmail() { + feedbackSubmitEmailByLine.show() + feedbackSubmitEmail.show() + } + + private fun ContentFeedbackSubmitBinding.hideEmail() { + feedbackSubmitEmailByLine.gone() + feedbackSubmitEmail.gone() + } + private fun DaxTextView.setClickableLink( annotation: String, spannableFullText: SpannableString, @@ -133,7 +175,11 @@ class SubscriptionFeedbackSubmitFragment : SubscriptionFeedbackFragment(R.layout } interface Listener { - fun onUserSubmit(description: String) + fun onUserSubmit( + description: String, + email: String? = null, + ) + fun onFaqsOpened() } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt index a6cd7d103d16..f5cbe3743b9c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt @@ -38,6 +38,7 @@ import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackSubsSubCat import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackSubsSubCategory.OTHER import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCancelled import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCompleted +import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackFailed import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.ShowHelpPages import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackFragmentState.FeedbackAction import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackFragmentState.FeedbackCategory @@ -64,6 +65,7 @@ class SubscriptionFeedbackViewModel @Inject constructor( private val pixelSender: PrivacyProUnifiedFeedbackPixelSender, private val feedbackCustomMetadataProvider: FeedbackCustomMetadataProvider, private val feedbackHelpUrlProvider: FeedbackHelpUrlProvider, + private val supportInbox: SubscriptionSupportInbox, ) : ViewModel() { private val viewState = MutableStateFlow(ViewState()) private val command = Channel(1, DROP_OLDEST) @@ -179,20 +181,43 @@ class SubscriptionFeedbackViewModel @Inject constructor( } } - fun onSubmitFeedback(description: String) { + fun onSubmitFeedback( + description: String, + email: String? = null, + ) { viewModelScope.launch { val metadata = viewState.value.feedbackMetadata.copy( description = description, ) when (metadata.reportType) { - GENERAL_FEEDBACK -> sendGeneralFeedbackPixel(metadata) - REQUEST_FEATURE -> sendFeatureRequestPixel(metadata) - REPORT_PROBLEM -> sendReportIssuePixel(metadata) + GENERAL_FEEDBACK -> { + sendGeneralFeedbackPixel(metadata) + command.send(FeedbackCompleted) + } + + REQUEST_FEATURE -> { + sendFeatureRequestPixel(metadata) + command.send(FeedbackCompleted) + } + + REPORT_PROBLEM -> { + if (!email.isNullOrBlank()) { + sendFeedbackToInbox(email, metadata) + } else { + true + }.also { completeFeedback -> + if (completeFeedback) { + sendReportIssuePixel(metadata) + command.send(FeedbackCompleted) + } else { + command.send(FeedbackFailed) + } + } + } + null -> {} // Do nothing } - - command.send(FeedbackCompleted) } } @@ -205,13 +230,33 @@ class SubscriptionFeedbackViewModel @Inject constructor( PARAMS_KEY_DESC to (metadata.description ?: ""), PARAMS_KEY_APP_NAME to (metadata.appName ?: ""), PARAMS_KEY_APP_PACKAGE to (metadata.appPackageName ?: ""), - PARAMS_KEY_CUSTOM_METADATA to feedbackCustomMetadataProvider.getCustomMetadata( + PARAMS_KEY_CUSTOM_METADATA to feedbackCustomMetadataProvider.getCustomMetadataEncoded( metadata.category, ), ), ) } + private suspend fun sendFeedbackToInbox( + email: String, + metadata: FeedbackMetadata, + ): Boolean { + return with(metadata) { + supportInbox.sendFeedback( + email = email, + source = source!!, + category = category!!, + subCategory = subCategory, + description = description, + appName = appName, + appPackage = appPackageName, + customMetadata = feedbackCustomMetadataProvider.getCustomMetadata( + category!!, + ), + ) + } + } + private fun sendFeatureRequestPixel(metadata: FeedbackMetadata) { pixelSender.sendPproFeatureRequest( mapOf( @@ -475,6 +520,7 @@ class SubscriptionFeedbackViewModel @Inject constructor( } sealed class Command { + data object FeedbackFailed : Command() data object FeedbackCompleted : Command() data object FeedbackCancelled : Command() data class ShowHelpPages(val url: String) : Command() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionSupportInbox.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionSupportInbox.kt new file mode 100644 index 000000000000..6c0ff67ab4c8 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionSupportInbox.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.feedback + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource +import com.duckduckgo.subscriptions.impl.services.FeedbackBody +import com.duckduckgo.subscriptions.impl.services.SubscriptionsService +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import logcat.logcat + +interface SubscriptionSupportInbox { + /** + * This methods send the feedback to the subscription BE feedback API whenever an email along a feedback. + * + * This is a suspend function because the operation can take time. + * You DO NOT need to set any dispatcher to call this suspend function. + */ + suspend fun sendFeedback( + email: String, + source: PrivacyProFeedbackSource, + category: SubscriptionFeedbackCategory, + subCategory: SubscriptionFeedbackSubCategory?, + description: String?, + appName: String?, + appPackage: String?, + customMetadata: String, + ): Boolean +} + +@ContributesBinding(ActivityScope::class) +class RealSubscriptionSupportInbox @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val subscriptionsService: SubscriptionsService, +) : SubscriptionSupportInbox { + override suspend fun sendFeedback( + email: String, + source: PrivacyProFeedbackSource, + category: SubscriptionFeedbackCategory, + subCategory: SubscriptionFeedbackSubCategory?, + description: String?, + appName: String?, + appPackage: String?, + customMetadata: String, + ): Boolean = withContext(dispatcherProvider.io()) { + runCatching { + logcat { "Support inbox: attempt to send feedback" } + subscriptionsService.feedback( + FeedbackBody( + userEmail = email, + feedbackSource = source.asParams().lowercase(), + problemCategory = category.asParams(), + problemSubCategory = subCategory?.asParams(), + customMetadata = customMetadata, + feedbackText = description?.trim(), + appName = appName, + appPackage = appPackage, + ), + ) + logcat { "Support inbox: feedback sent!" } + true + }.getOrElse { + logcat { "Support inbox: failed to send feedback. Reason ${it.message}" } + false + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index f08d21805d50..ca6f293ba80e 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -39,6 +39,12 @@ interface SubscriptionsService { suspend fun confirm( @Body confirmationBody: ConfirmationBody, ): ConfirmationResponse + + @AuthRequired + @POST("https://subscriptions.duckduckgo.com/api/feedback") + suspend fun feedback( + @Body feedbackBody: FeedbackBody, + ): FeedbackResponse } data class PortalResponse(val customerPortalUrl: String) @@ -70,3 +76,19 @@ data class ConfirmationEntitlement( fun List.toEntitlements(): List { return this.map { Entitlement(it.name, it.product) } } + +data class FeedbackBody( + val userEmail: String, + val platform: String = "android", + val feedbackSource: String, + val problemCategory: String, + val customMetadata: String?, + val feedbackText: String?, + val appName: String?, + val appPackage: String?, + val problemSubCategory: String?, +) + +data class FeedbackResponse( + val message: String, +) diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_submit.xml b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_submit.xml index 2e1bf5bad5c3..9916c6bc50c8 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_submit.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_submit.xml @@ -29,21 +29,32 @@ android:id="@+id/feedbackSubmitHeader" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/keyline_4" + android:layout_marginTop="@dimen/keyline_2" android:text="@string/feedbackSubmitVpnHeader" /> - - + + + + + android:text="@string/feedbackSubmitVpnSubmit" + android:enabled="false" + app:buttonSize="large" /> \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml index 2ac887cdb0d8..eff7b252a274 100644 --- a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml +++ b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml @@ -141,11 +141,14 @@ Other Found an issue not covered in our help center ? We definitely want to know about it. - FEEDBACK Tell us what’s going on… - In addition to details entered above, we send some anonymized info with your feedback:\n\n• Whether some browser features are active\n• Aggregate app diagnostics (e.g., error codes)\n\n By clicking "Submit" you agree that DuckDuckGo may use information submitted to improve the app. + In addition to details entered above, we send some anonymized info with your feedback:\n\n• Whether some browser features are active\n• Aggregate app diagnostics (e.g., error codes)\n\nBy clicking "Submit" you agree that DuckDuckGo may use information submitted to improve the app. Submit Thank You! Feedback submitted. + Something went wrong + + Provide an email if you\'d like us to contact you about this issue (we may not be able to respond to all issues): + Email (optional) Please give us your feedback… What feature would you like to see? diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeCustomMetadataProvider.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeCustomMetadataProvider.kt new file mode 100644 index 000000000000..1fa6df3b2875 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeCustomMetadataProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.feedback + +class FakeCustomMetadataProvider : FeedbackCustomMetadataProvider { + override suspend fun getCustomMetadata( + category: SubscriptionFeedbackCategory, + appPackageId: String?, + ): String = when (category) { + SubscriptionFeedbackCategory.VPN -> "VPN raw metadata" + SubscriptionFeedbackCategory.ITR -> "ITR raw metadata" + SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS -> "SUBS_AND_PAYMENTS raw metadata" + SubscriptionFeedbackCategory.PIR -> "PIR raw metadata" + else -> "" + } + + override suspend fun getCustomMetadataEncoded( + category: SubscriptionFeedbackCategory, + appPackageId: String?, + ): String = when (category) { + SubscriptionFeedbackCategory.VPN -> "VPN encoded metadata" + SubscriptionFeedbackCategory.ITR -> "ITR encoded metadata" + SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS -> "SUBS_AND_PAYMENTS encoded metadata" + SubscriptionFeedbackCategory.PIR -> "PIR encoded metadata" + else -> "" + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeSubscriptionSupportInbox.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeSubscriptionSupportInbox.kt new file mode 100644 index 000000000000..083bb080a361 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/FakeSubscriptionSupportInbox.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.feedback + +import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource +import com.duckduckgo.subscriptions.impl.services.FeedbackBody + +class FakeSubscriptionSupportInbox : SubscriptionSupportInbox { + private var _sendSucceeds: Boolean = true + private var _lastFeedbackBody: FeedbackBody? = null + fun setSendFeedbackResult(success: Boolean) { + _sendSucceeds = success + } + + fun getLastSentFeedback(): FeedbackBody? = _lastFeedbackBody + + override suspend fun sendFeedback( + email: String, + source: PrivacyProFeedbackSource, + category: SubscriptionFeedbackCategory, + subCategory: SubscriptionFeedbackSubCategory?, + description: String?, + appName: String?, + appPackage: String?, + customMetadata: String, + ): Boolean { + _lastFeedbackBody = FeedbackBody( + userEmail = email, + feedbackSource = source.asParams(), + problemCategory = category.asParams(), + customMetadata = customMetadata, + feedbackText = description, + appName = appName, + appPackage = appPackage, + problemSubCategory = subCategory?.asParams(), + ) + return _sendSucceeds + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt index c38a902356e8..091dbf50c0de 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt @@ -15,6 +15,8 @@ import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackReportType import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackReportType.REPORT_PROBLEM import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackReportType.REQUEST_FEATURE import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCancelled +import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackCompleted +import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.FeedbackFailed import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.Command.ShowHelpPages import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackFragmentState.FeedbackAction import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackFragmentState.FeedbackCategory @@ -24,9 +26,12 @@ import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel. import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackViewModel.FeedbackMetadata import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackVpnSubCategory.BROWSER_CRASH_FREEZE import com.duckduckgo.subscriptions.impl.feedback.pixels.PrivacyProUnifiedFeedbackPixelSender +import com.duckduckgo.subscriptions.impl.services.FeedbackBody import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -34,6 +39,7 @@ import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.any +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -45,21 +51,23 @@ class SubscriptionFeedbackViewModelTest { @Mock private lateinit var pixelSender: PrivacyProUnifiedFeedbackPixelSender - @Mock - private lateinit var customMetadataProvider: FeedbackCustomMetadataProvider - @Mock private lateinit var feedbackHelpUrlProvider: FeedbackHelpUrlProvider + private lateinit var supportInbox: FakeSubscriptionSupportInbox + private lateinit var customMetadataProvider: FakeCustomMetadataProvider private lateinit var viewModel: SubscriptionFeedbackViewModel @Before fun setUp() { MockitoAnnotations.openMocks(this) + customMetadataProvider = FakeCustomMetadataProvider() + supportInbox = FakeSubscriptionSupportInbox() viewModel = SubscriptionFeedbackViewModel( pixelSender, customMetadataProvider, feedbackHelpUrlProvider, + supportInbox, ) } @@ -855,7 +863,6 @@ class SubscriptionFeedbackViewModelTest { @Test fun whenSubsIssueSubmittedTheSendReportIssuePixel() = runTest { - whenever(customMetadataProvider.getCustomMetadata(SUBS_AND_PAYMENTS)).thenReturn("testMetadata") viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS) viewModel.onReportTypeSelected(REPORT_PROBLEM) viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER) @@ -867,7 +874,7 @@ class SubscriptionFeedbackViewModelTest { "category" to "subscription", "subcategory" to "somethingElse", "description" to "Test", - "customMetadata" to "testMetadata", + "customMetadata" to "SUBS_AND_PAYMENTS encoded metadata", "appName" to "", "appPackage" to "", ), @@ -876,7 +883,6 @@ class SubscriptionFeedbackViewModelTest { @Test fun whenVpnIssueSubmittedTheSendReportIssuePixel() = runTest { - whenever(customMetadataProvider.getCustomMetadata(VPN)).thenReturn("testMetadata") viewModel.allowUserToChooseReportType(VPN_MANAGEMENT) viewModel.onReportTypeSelected(REPORT_PROBLEM) viewModel.onSubcategorySelected(SubscriptionFeedbackVpnSubCategory.CANNOT_CONNECT_TO_LOCAL_DEVICE) @@ -888,7 +894,7 @@ class SubscriptionFeedbackViewModelTest { "category" to "vpn", "subcategory" to "cantConnectToLocalDevice", "description" to "Test", - "customMetadata" to "testMetadata", + "customMetadata" to "VPN encoded metadata", "appName" to "", "appPackage" to "", ), @@ -897,7 +903,6 @@ class SubscriptionFeedbackViewModelTest { @Test fun whenAppSpecificVpnIssueSubmittedTheSendReportIssuePixel() = runTest { - whenever(customMetadataProvider.getCustomMetadata(VPN)).thenReturn("testMetadata") viewModel.allowUserToReportAppIssue("test", "com.test") viewModel.onSubmitFeedback("Test") @@ -907,7 +912,7 @@ class SubscriptionFeedbackViewModelTest { "category" to "vpn", "subcategory" to "issueWithAppOrWebsite", "description" to "Test", - "customMetadata" to "testMetadata", + "customMetadata" to "VPN encoded metadata", "appName" to "test", "appPackage" to "com.test", ), @@ -916,7 +921,6 @@ class SubscriptionFeedbackViewModelTest { @Test fun whenPIRIssueSubmittedTheSendReportIssuePixel() = runTest { - whenever(customMetadataProvider.getCustomMetadata(PIR)).thenReturn("") viewModel.allowUserToChooseReportType(DDG_SETTINGS) viewModel.onReportTypeSelected(REPORT_PROBLEM) viewModel.onCategorySelected(PIR) @@ -929,7 +933,7 @@ class SubscriptionFeedbackViewModelTest { "category" to "pir", "subcategory" to "removalStuck", "description" to "Test", - "customMetadata" to "", + "customMetadata" to "PIR encoded metadata", "appName" to "", "appPackage" to "", ), @@ -938,7 +942,6 @@ class SubscriptionFeedbackViewModelTest { @Test fun whenITRIssueSubmittedTheSendReportIssuePixel() = runTest { - whenever(customMetadataProvider.getCustomMetadata(ITR)).thenReturn("") viewModel.allowUserToChooseReportType(DDG_SETTINGS) viewModel.onReportTypeSelected(REPORT_PROBLEM) viewModel.onCategorySelected(ITR) @@ -951,11 +954,100 @@ class SubscriptionFeedbackViewModelTest { "category" to "itr", "subcategory" to "advisorUnhelpful", "description" to "Test", - "customMetadata" to "", + "customMetadata" to "ITR encoded metadata", + "appName" to "", + "appPackage" to "", + ), + ) + } + + @Test + fun whenSubscriptionFeedbackWithEmailSucceedsThenSendBothToSupportInboxAndPixel() = runTest { + viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS) + viewModel.onReportTypeSelected(REPORT_PROBLEM) + viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER) + viewModel.onSubmitFeedback("Test", "test@mail.com") + + verify(pixelSender).sendPproReportIssue( + mapOf( + "source" to "ppro", + "category" to "subscription", + "subcategory" to "somethingElse", + "description" to "Test", + "customMetadata" to "SUBS_AND_PAYMENTS encoded metadata", + "appName" to "", + "appPackage" to "", + ), + ) + + assertNotNull(supportInbox.getLastSentFeedback()) + assertEquals( + FeedbackBody( + userEmail = "test@mail.com", + platform = "android", + feedbackSource = "ppro", + problemCategory = "subscription", + customMetadata = "SUBS_AND_PAYMENTS raw metadata", + feedbackText = "Test", + appName = null, + appPackage = null, + problemSubCategory = "somethingElse", + ), + supportInbox.getLastSentFeedback(), + ) + viewModel.commands().test { + assertEquals(FeedbackCompleted, expectMostRecentItem()) + } + } + + @Test + fun whenSubscriptionFeedbackWithEmailFailsThenSendNothingAndShowFailed() = runTest { + supportInbox.setSendFeedbackResult(false) + viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS) + viewModel.onReportTypeSelected(REPORT_PROBLEM) + viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER) + viewModel.onSubmitFeedback("Test", "test@mail.com") + + verify(pixelSender, never()).sendPproReportIssue( + mapOf( + "source" to "ppro", + "category" to "subscription", + "subcategory" to "somethingElse", + "description" to "Test", + "customMetadata" to "SUBS_AND_PAYMENTS encoded metadata", + "appName" to "", + "appPackage" to "", + ), + ) + + viewModel.commands().test { + assertEquals(FeedbackFailed, expectMostRecentItem()) + } + } + + @Test + fun whenSubscriptionFeedbackWithBlankEmailThenSendPixelOnly() = runTest { + viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS) + viewModel.onReportTypeSelected(REPORT_PROBLEM) + viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER) + viewModel.onSubmitFeedback("Test", " ") + + verify(pixelSender).sendPproReportIssue( + mapOf( + "source" to "ppro", + "category" to "subscription", + "subcategory" to "somethingElse", + "description" to "Test", + "customMetadata" to "SUBS_AND_PAYMENTS encoded metadata", "appName" to "", "appPackage" to "", ), ) + + assertNull(supportInbox.getLastSentFeedback()) + viewModel.commands().test { + assertEquals(FeedbackCompleted, expectMostRecentItem()) + } } private fun SubscriptionFeedbackViewModel.ViewState.assertViewStateMoveForward( From 3245b7d3e6bc803baab6edede918760bf4fe60d2 Mon Sep 17 00:00:00 2001 From: Mike Scamell Date: Thu, 7 Nov 2024 11:01:15 +0000 Subject: [PATCH 03/26] Feature branch for Show on App Launch (#4944) Task/Issue URL: https://app.asana.com/0/1207908166761516/1208167186867401/f ### Description This is the feature branch for the implementation of https://app.asana.com/0/1207908166761516/1207644558291046/f ### Steps to test this PR See stacked PRs. --------- Co-authored-by: Marcos Holgado Co-authored-by: Dax The Translator --- .../app/browser/BrowserTabViewModelTest.kt | 11 + .../com/duckduckgo/app/tabs/db/TabsDaoTest.kt | 30 + app/src/main/AndroidManifest.xml | 5 + .../duckduckgo/app/browser/BrowserActivity.kt | 7 + .../app/browser/BrowserTabViewModel.kt | 11 + .../app/browser/BrowserViewModel.kt | 13 + .../com/duckduckgo/app/fire/FireActivity.kt | 2 +- .../GeneralSettingsActivity.kt | 40 + .../GeneralSettingsViewModel.kt | 44 + .../ShowOnAppLaunchActivity.kt | 137 +++ .../showonapplaunch/ShowOnAppLaunchFeature.kt | 31 + .../ShowOnAppLaunchOptionHandler.kt | 122 +++ .../showonapplaunch/ShowOnAppLaunchScreens.kt | 24 + .../ShowOnAppLaunchStateReporterPlugin.kt | 53 ++ .../ShowOnAppLaunchUrlConverterImpl.kt | 53 ++ .../ShowOnAppLaunchViewModel.kt | 87 ++ .../showonapplaunch/UrlConverter.kt | 22 + .../model/ShowOnAppLaunchOption.kt | 50 + .../store/ShowOnAppLaunchDataStoreModule.kt | 48 + .../store/ShowOnAppLaunchOptionDataStore.kt | 121 +++ .../com/duckduckgo/app/pixels/AppPixelName.kt | 4 + .../app/tabs/model/TabDataRepository.kt | 5 + .../res/layout/activity_general_settings.xml | 11 + .../activity_show_on_app_launch_setting.xml | 73 ++ app/src/main/res/values-bg/strings.xml | 6 + app/src/main/res/values-cs/strings.xml | 6 + app/src/main/res/values-da/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-el/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-et/strings.xml | 6 + app/src/main/res/values-fi/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-hr/strings.xml | 6 + app/src/main/res/values-hu/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-lt/strings.xml | 6 + app/src/main/res/values-lv/strings.xml | 6 + app/src/main/res/values-nb/strings.xml | 6 + app/src/main/res/values-nl/strings.xml | 6 + app/src/main/res/values-pl/strings.xml | 6 + app/src/main/res/values-pt/strings.xml | 6 + app/src/main/res/values-ro/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-sk/strings.xml | 6 + app/src/main/res/values-sl/strings.xml | 6 + app/src/main/res/values-sv/strings.xml | 6 + app/src/main/res/values-tr/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../app/browser/BrowserViewModelTest.kt | 86 +- .../GeneralSettingsViewModelTest.kt | 160 +++- .../ShowOnAppLaunchOptionHandlerImplTest.kt | 855 ++++++++++++++++++ .../ShowOnAppLaunchStateReporterPluginTest.kt | 70 ++ .../ShowOnAppLaunchUrlConverterImplTest.kt | 143 +++ .../ShowOnAppLaunchViewModelTest.kt | 120 +++ .../FakeShowOnAppLaunchOptionDataStore.kt | 53 ++ .../ShowOnAppLaunchPrefsDataStoreTest.kt | 115 +++ .../java/com/duckduckgo/fakes/FakePixel.kt | 60 ++ .../app/tabs/model/TabRepository.kt | 4 + .../common/ui/view/listitem}/RadioListItem.kt | 24 +- .../common/ui/view/text/DaxTextInput.kt | 5 + .../main/res/layout/view_radio_list_item.xml | 1 + .../main/res/values/attrs-radio-list-item.xml | 1 + .../duckduckgo/common/utils/UriExtension.kt | 3 + .../res/layout/activity_netp_geoswitching.xml | 2 +- .../res/layout/item_geoswitching_country.xml | 3 +- .../duckduckgo/app/statistics/pixels/Pixel.kt | 1 + 67 files changed, 2811 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt create mode 100644 app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt create mode 100644 app/src/main/res/layout/activity_show_on_app_launch_setting.xml create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt create mode 100644 app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt create mode 100644 app/src/test/java/com/duckduckgo/fakes/FakePixel.kt rename {network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching => common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem}/RadioListItem.kt (84%) rename {network-protection/network-protection-impl => common/common-ui}/src/main/res/layout/view_radio_list_item.xml (99%) rename {network-protection/network-protection-impl => common/common-ui}/src/main/res/values/attrs-radio-list-item.xml (96%) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 150a7432996d..21ae646d4689 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -133,6 +133,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepositoryImpl import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.global.install.AppInstallStore @@ -412,6 +413,8 @@ class BrowserTabViewModelTest { private var loadingBarExperimentManager: LoadingBarExperimentManager = mock() + private val mockShowOnAppLaunchHandler: ShowOnAppLaunchOptionHandler = mock() + private lateinit var remoteMessagingModel: RemoteMessagingModel private val lazyFaviconManager = Lazy { mockFaviconManager } @@ -669,6 +672,7 @@ class BrowserTabViewModelTest { changeOmnibarPositionFeature = changeOmnibarPositionFeature, highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, privacyProtectionTogglePlugin = protectionTogglePluginPoint, + showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler, ) testee.loadData("abc", null, false, false) @@ -6127,6 +6131,13 @@ class BrowserTabViewModelTest { } } + @Test + fun whenNavigationStateChangedCalledThenHandleResolvedUrlIsChecked() = runTest { + testee.navigationStateChanged(buildWebNavigation("https://example.com")) + + verify(mockShowOnAppLaunchHandler).handleResolvedUrlStorage(eq("https://example.com"), any(), any()) + } + private fun aCredential(): LoginCredentials { return LoginCredentials(domain = null, username = null, password = null) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt index 422c12dcbb60..e9c6d1d1e72e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt @@ -22,6 +22,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -337,4 +338,33 @@ class TabsDaoTest { assertEquals(tab.copy(deletable = false), testee.tab(tab.tabId)) } + + @Test + fun whenSelectTabByUrlAndTabExistsThenTabIdReturned() = runTest { + val tab = TabEntity( + tabId = "TAB_ID", + url = "https://www.duckduckgo.com/", + position = 0, + deletable = true, + ) + + testee.insertTab(tab) + val tabId = testee.selectTabByUrl("https://www.duckduckgo.com/") + + assertEquals(tabId, tab.tabId) + } + + @Test + fun whenSelectTabByUrlAndTabDoesNotExistThenNullReturned() = runTest { + val tab = TabEntity( + tabId = "TAB_ID", + url = "https://www.duckduckgo.com/", + position = 0, + ) + + testee.insertTab(tab) + val tabId = testee.selectTabByUrl("https://www.quackquackno.com/") + + assertNull(tabId) + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e6474231c8e2..534d53d49c85 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -418,6 +418,11 @@ android:exported="false" android:label="@string/generalSettingsActivityTitle" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + , + private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -1329,6 +1331,15 @@ class BrowserTabViewModel @Inject constructor( override fun navigationStateChanged(newWebNavigationState: WebNavigationState) { val stateChange = newWebNavigationState.compare(webNavigationState) + + viewModelScope.launch { + showOnAppLaunchOptionHandler.handleResolvedUrlStorage( + currentUrl = newWebNavigationState.currentUrl, + isRootOfTab = !newWebNavigationState.canGoBack, + tabId = tabId, + ) + } + webNavigationState = newWebNavigationState if (!currentBrowserViewState().browserShowing) return diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 73686393e4c7..fcf92bc0d38f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -20,11 +20,14 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.fire.DataClearer +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions @@ -69,6 +72,8 @@ class BrowserViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val pixel: Pixel, private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature, + private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, + private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, ) : ViewModel(), CoroutineScope { @@ -290,6 +295,14 @@ class BrowserViewModel @Inject constructor( tabRepository.select(tabId) } } + + fun handleShowOnAppLaunchOption() { + if (showOnAppLaunchFeature.self().isEnabled()) { + viewModelScope.launch { + showOnAppLaunchOptionHandler.handleAppLaunchOption() + } + } + } } /** diff --git a/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt b/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt index 5143c0d42a15..97dc42580371 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt @@ -80,7 +80,7 @@ class FireActivity : AppCompatActivity() { context: Context, notifyDataCleared: Boolean = false, ): Intent { - val intent = BrowserActivity.intent(context, notifyDataCleared = notifyDataCleared) + val intent = BrowserActivity.intent(context, notifyDataCleared = notifyDataCleared, isLaunchFromClearDataAction = true) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) return intent } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt index b3df78f9eba7..4de78cf371cc 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.generalsettings import android.os.Bundle +import android.view.View.OnClickListener import android.widget.CompoundButton import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle @@ -24,7 +25,16 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityGeneralSettingsBinding +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command.LaunchShowOnAppLaunchScreen +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchScreenNoParams +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.global.view.fadeTransitionConfig import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope @@ -55,6 +65,10 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { viewModel.onVoiceSearchChanged(isChecked) } + private val showOnAppLaunchClickListener = OnClickListener { + viewModel.onShowOnAppLaunchButtonClick() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,6 +83,7 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { binding.autocompleteToggle.setOnCheckedChangeListener(autocompleteToggleListener) binding.autocompleteRecentlyVisitedSitesToggle.setOnCheckedChangeListener(autocompleteRecentlyVisitedSitesToggleListener) binding.voiceSearchToggle.setOnCheckedChangeListener(voiceSearchChangeListener) + binding.showOnAppLaunchButton.setOnClickListener(showOnAppLaunchClickListener) } private fun observeViewModel() { @@ -94,7 +109,32 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { binding.voiceSearchToggle.isVisible = true binding.voiceSearchToggle.quietlySetIsChecked(viewState.voiceSearchEnabled, voiceSearchChangeListener) } + + binding.showOnAppLaunchButton.isVisible = it.isShowOnAppLaunchOptionVisible + setShowOnAppLaunchOptionSecondaryText(viewState.showOnAppLaunchSelectedOption) } }.launchIn(lifecycleScope) + + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun setShowOnAppLaunchOptionSecondaryText(showOnAppLaunchOption: ShowOnAppLaunchOption) { + val optionString = when (showOnAppLaunchOption) { + is LastOpenedTab -> getString(R.string.showOnAppLaunchOptionLastOpenedTab) + is NewTabPage -> getString(R.string.showOnAppLaunchOptionNewTabPage) + is SpecificPage -> showOnAppLaunchOption.url + } + binding.showOnAppLaunchButton.setSecondaryText(optionString) + } + + private fun processCommand(command: Command) { + when (command) { + LaunchShowOnAppLaunchScreen -> { + globalActivityStarter.start(this, ShowOnAppLaunchScreenNoParams, fadeTransitionConfig()) + } + } } } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt index c859b2532060..3e7a786074fc 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt @@ -19,6 +19,10 @@ package com.duckduckgo.app.generalsettings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_GENERAL_SETTINGS_TOGGLED_OFF import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_GENERAL_SETTINGS_TOGGLED_ON import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_RECENT_SITES_GENERAL_SETTINGS_TOGGLED_OFF @@ -33,8 +37,15 @@ import com.duckduckgo.voice.impl.VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETT import com.duckduckgo.voice.impl.VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_ON import com.duckduckgo.voice.store.VoiceSearchRepository import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -46,6 +57,8 @@ class GeneralSettingsViewModel @Inject constructor( private val voiceSearchAvailability: VoiceSearchAvailability, private val voiceSearchRepository: VoiceSearchRepository, private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, ) : ViewModel() { data class ViewState( @@ -54,11 +67,20 @@ class GeneralSettingsViewModel @Inject constructor( val storeHistoryEnabled: Boolean, val showVoiceSearch: Boolean, val voiceSearchEnabled: Boolean, + val isShowOnAppLaunchOptionVisible: Boolean, + val showOnAppLaunchSelectedOption: ShowOnAppLaunchOption, ) + sealed class Command { + data object LaunchShowOnAppLaunchScreen : Command() + } + private val _viewState = MutableStateFlow(null) val viewState = _viewState.asStateFlow() + private val _commands = Channel(1, BufferOverflow.DROP_OLDEST) + val commands = _commands.receiveAsFlow() + init { viewModelScope.launch(dispatcherProvider.io()) { val autoCompleteEnabled = settingsDataStore.autoCompleteSuggestionsEnabled @@ -71,8 +93,12 @@ class GeneralSettingsViewModel @Inject constructor( storeHistoryEnabled = history.isHistoryFeatureAvailable(), showVoiceSearch = voiceSearchAvailability.isVoiceSearchSupported, voiceSearchEnabled = voiceSearchAvailability.isVoiceSearchAvailable, + isShowOnAppLaunchOptionVisible = showOnAppLaunchFeature.self().isEnabled(), + showOnAppLaunchSelectedOption = showOnAppLaunchOptionDataStore.optionFlow.first(), ) } + + observeShowOnAppLaunchOption() } fun onAutocompleteSettingChanged(enabled: Boolean) { @@ -119,4 +145,22 @@ class GeneralSettingsViewModel @Inject constructor( _viewState.value = _viewState.value?.copy(voiceSearchEnabled = voiceSearchAvailability.isVoiceSearchAvailable) } } + + fun onShowOnAppLaunchButtonClick() { + sendCommand(Command.LaunchShowOnAppLaunchScreen) + pixel.fire(AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_PRESSED) + } + + private fun observeShowOnAppLaunchOption() { + showOnAppLaunchOptionDataStore.optionFlow + .onEach { showOnAppLaunchOption -> + _viewState.update { it!!.copy(showOnAppLaunchSelectedOption = showOnAppLaunchOption) } + }.launchIn(viewModelScope) + } + + private fun sendCommand(newCommand: Command) { + viewModelScope.launch { + _commands.send(newCommand) + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt new file mode 100644 index 000000000000..62196c90eca5 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.os.Bundle +import android.view.MenuItem +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.databinding.ActivityShowOnAppLaunchSettingBinding +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(ShowOnAppLaunchScreenNoParams::class) +class ShowOnAppLaunchActivity : DuckDuckGoActivity() { + + private val viewModel: ShowOnAppLaunchViewModel by bindViewModel() + private val binding: ActivityShowOnAppLaunchSettingBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + binding.specificPageUrlInput.setSelectAllOnFocus(true) + + configureUiEventHandlers() + observeViewModel() + } + + override fun onPause() { + super.onPause() + viewModel.setSpecificPageUrl(binding.specificPageUrlInput.text) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun configureUiEventHandlers() { + binding.lastOpenedTabCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(LastOpenedTab) + } + + binding.newTabCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(NewTabPage) + } + + binding.specificPageCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(SpecificPage(binding.specificPageUrlInput.text)) + } + + binding.specificPageUrlInput.addFocusChangedListener { _, hasFocus -> + if (hasFocus) { + viewModel.onShowOnAppLaunchOptionChanged( + SpecificPage(binding.specificPageUrlInput.text), + ) + } + } + } + + private fun observeViewModel() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .onEach { viewState -> + when (viewState.selectedOption) { + LastOpenedTab -> { + uncheckNewTabCheckListItem() + uncheckSpecificPageCheckListItem() + binding.lastOpenedTabCheckListItem.setChecked(true) + } + NewTabPage -> { + uncheckLastOpenedTabCheckListItem() + uncheckSpecificPageCheckListItem() + binding.newTabCheckListItem.setChecked(true) + } + is SpecificPage -> { + uncheckLastOpenedTabCheckListItem() + uncheckNewTabCheckListItem() + with(binding) { + specificPageCheckListItem.setChecked(true) + specificPageUrlInput.isEditable = true + } + } + } + + if (binding.specificPageUrlInput.text.isBlank()) { + binding.specificPageUrlInput.text = viewState.specificPageUrl + } + } + .launchIn(lifecycleScope) + } + + private fun uncheckLastOpenedTabCheckListItem() { + binding.lastOpenedTabCheckListItem.setChecked(false) + } + + private fun uncheckNewTabCheckListItem() { + binding.newTabCheckListItem.setChecked(false) + } + + private fun uncheckSpecificPageCheckListItem() { + binding.specificPageCheckListItem.setChecked(false) + binding.specificPageUrlInput.isEditable = false + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt new file mode 100644 index 000000000000..a89d1a91ea32 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "showOnAppLaunch", +) +interface ShowOnAppLaunchFeature { + + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt new file mode 100644 index 000000000000..a0a2a0332018 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.net.Uri +import androidx.core.net.toUri +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.isHttpOrHttps +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +interface ShowOnAppLaunchOptionHandler { + suspend fun handleAppLaunchOption() + suspend fun handleResolvedUrlStorage( + currentUrl: String?, + isRootOfTab: Boolean, + tabId: String, + ) +} + +@ContributesBinding(AppScope::class) +class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( + private val dispatchers: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, + private val tabRepository: TabRepository, +) : ShowOnAppLaunchOptionHandler { + + override suspend fun handleAppLaunchOption() { + when (val option = showOnAppLaunchOptionDataStore.optionFlow.first()) { + LastOpenedTab -> Unit + NewTabPage -> tabRepository.add() + is SpecificPage -> handleSpecificPageOption(option) + } + } + + override suspend fun handleResolvedUrlStorage( + currentUrl: String?, + isRootOfTab: Boolean, + tabId: String, + ) { + withContext(dispatchers.io()) { + val shouldSaveCurrentUrlForShowOnAppLaunch = currentUrl != null && + isRootOfTab && + tabId == showOnAppLaunchOptionDataStore.showOnAppLaunchTabId + + if (shouldSaveCurrentUrlForShowOnAppLaunch) { + showOnAppLaunchOptionDataStore.setResolvedPageUrl(currentUrl!!) + } + } + } + + private suspend fun handleSpecificPageOption(option: SpecificPage) { + val userUri = option.url.toUri() + val resolvedUri = option.resolvedUrl?.toUri() + + val urls = listOfNotNull(userUri, resolvedUri).map { uri -> + stripIfHttpOrHttps(uri) + } + + val tabIdUrlMap = getTabIdUrlMap(tabRepository.flowTabs.first()) + + val existingTabId = tabIdUrlMap.entries.findLast { it.value in urls }?.key + + if (existingTabId != null) { + showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(existingTabId) + tabRepository.select(existingTabId) + } else { + val tabId = tabRepository.add(url = option.url) + showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(tabId) + } + } + + private fun stripIfHttpOrHttps(uri: Uri): String { + return if (uri.isHttpOrHttps) { + stripUri(uri) + } else { + uri.toString() + } + } + + private fun stripUri(uri: Uri): String = uri.run { + val authority = uri.authority?.removePrefix("www.") + uri.buildUpon() + .scheme(null) + .authority(authority) + .toString() + .replaceFirst("//", "") + } + + private fun getTabIdUrlMap(tabs: List): Map { + return tabs + .filterNot { tab -> tab.url.isNullOrBlank() } + .associate { tab -> + val tabUri = tab.url!!.toUri() + val strippedUrl = stripIfHttpOrHttps(tabUri) + tab.tabId to strippedUrl + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt new file mode 100644 index 000000000000..7bc928f69542 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams + +/** + * Use this model to launch the Show On App Launch screen + */ +object ShowOnAppLaunchScreenNoParams : ActivityParams diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt new file mode 100644 index 000000000000..2bfcbbac34f7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.api.BrowserFeatureStateReporterPlugin +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +interface ShowOnAppLaunchReporterPlugin + +@ContributesMultibinding( + scope = AppScope::class, + boundType = BrowserFeatureStateReporterPlugin::class, +) +@ContributesBinding(scope = AppScope::class, boundType = ShowOnAppLaunchReporterPlugin::class) +class ShowOnAppLaunchStateReporterPlugin +@Inject +constructor( + private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, +) : ShowOnAppLaunchReporterPlugin, BrowserFeatureStateReporterPlugin { + + override fun featureStateParams(): Map { + val option = + runBlocking(dispatcherProvider.io()) { + showOnAppLaunchOptionDataStore.optionFlow.first() + } + val dailyPixelValue = ShowOnAppLaunchOption.getDailyPixelValue(option) + return mapOf(PixelParameter.LAUNCH_SCREEN to dailyPixelValue) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt new file mode 100644 index 000000000000..bb218d89505a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.net.Uri +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore + +class ShowOnAppLaunchUrlConverterImpl : UrlConverter { + + override fun convertUrl(url: String?): String { + if (url.isNullOrBlank()) return ShowOnAppLaunchOptionDataStore.DEFAULT_SPECIFIC_PAGE_URL + + val uri = Uri.parse(url.trim()) + + val uriWithScheme = if (uri.scheme == null) { + Uri.Builder() + .scheme("http") + .authority(uri.path?.lowercase()) + } else { + uri.buildUpon() + .scheme(uri.scheme?.lowercase()) + .authority(uri.authority?.lowercase()) + } + .apply { + query(uri.query) + fragment(uri.fragment) + } + + val uriWithPath = if (uri.path.isNullOrBlank()) { + uriWithScheme.path("/") + } else { + uriWithScheme + } + + val processedUrl = uriWithPath.build().toString() + + return Uri.decode(processedUrl) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt new file mode 100644 index 000000000000..965d3cdc3dcc --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesViewModel(ActivityScope::class) +class ShowOnAppLaunchViewModel @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, + private val urlConverter: UrlConverter, + private val pixel: Pixel, +) : ViewModel() { + + data class ViewState( + val selectedOption: ShowOnAppLaunchOption, + val specificPageUrl: String, + ) + + private val _viewState = MutableStateFlow(null) + val viewState = _viewState.asStateFlow().filterNotNull() + + init { + observeShowOnAppLaunchOptionChanges() + } + + private fun observeShowOnAppLaunchOptionChanges() { + combine( + showOnAppLaunchOptionDataStore.optionFlow, + showOnAppLaunchOptionDataStore.specificPageUrlFlow, + ) { option, specificPageUrl -> + _viewState.value = ViewState(option, specificPageUrl) + }.flowOn(dispatcherProvider.io()) + .launchIn(viewModelScope) + } + + fun onShowOnAppLaunchOptionChanged(option: ShowOnAppLaunchOption) { + Timber.i("User changed show on app launch option to $option") + viewModelScope.launch(dispatcherProvider.io()) { + firePixel(option) + showOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(option) + } + } + + fun setSpecificPageUrl(url: String) { + Timber.i("Setting specific page url to $url") + viewModelScope.launch(dispatcherProvider.io()) { + val convertedUrl = urlConverter.convertUrl(url) + showOnAppLaunchOptionDataStore.setSpecificPageUrl(convertedUrl) + } + } + + private fun firePixel(option: ShowOnAppLaunchOption) { + val pixelName = ShowOnAppLaunchOption.getPixelName(option) + pixel.fire(pixelName) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt new file mode 100644 index 000000000000..87703ab36a73 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +interface UrlConverter { + + fun convertUrl(url: String?): String +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt new file mode 100644 index 000000000000..4552c471cfbf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.model + +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED + +sealed class ShowOnAppLaunchOption(val id: Int) { + + data object LastOpenedTab : ShowOnAppLaunchOption(1) + data object NewTabPage : ShowOnAppLaunchOption(2) + data class SpecificPage(val url: String, val resolvedUrl: String? = null) : ShowOnAppLaunchOption(3) + + companion object { + + fun mapToOption(id: Int): ShowOnAppLaunchOption = when (id) { + 1 -> LastOpenedTab + 2 -> NewTabPage + 3 -> SpecificPage("") + else -> throw IllegalArgumentException("Unknown id: $id") + } + + fun getPixelName(option: ShowOnAppLaunchOption) = when (option) { + LastOpenedTab -> SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED + NewTabPage -> SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED + is SpecificPage -> SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED + } + + fun getDailyPixelValue(option: ShowOnAppLaunchOption) = when (option) { + LastOpenedTab -> "last_opened_tab" + NewTabPage -> "new_tab_page" + is SpecificPage -> "specific_page" + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt new file mode 100644 index 000000000000..291efa1fe04a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchUrlConverterImpl +import com.duckduckgo.app.generalsettings.showonapplaunch.UrlConverter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object ShowOnAppLaunchDataStoreModule { + + private val Context.showOnAppLaunchDataStore: DataStore by preferencesDataStore( + name = "show_on_app_launch", + ) + + @Provides + @ShowOnAppLaunch + fun showOnAppLaunchDataStore(context: Context): DataStore = context.showOnAppLaunchDataStore + + @Provides + fun showOnAppLaunchUrlConverter(): UrlConverter = ShowOnAppLaunchUrlConverterImpl() +} + +@Qualifier +annotation class ShowOnAppLaunch diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt new file mode 100644 index 000000000000..3afb3f017cd7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore.Companion.DEFAULT_SPECIFIC_PAGE_URL +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ShowOnAppLaunchOptionDataStore { + val optionFlow: Flow + val specificPageUrlFlow: Flow + val showOnAppLaunchTabId: String? + + fun setShowOnAppLaunchTabId(tabId: String) + suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) + suspend fun setSpecificPageUrl(url: String) + suspend fun setResolvedPageUrl(url: String) + + companion object { + const val DEFAULT_SPECIFIC_PAGE_URL = "https://duckduckgo.com/" + } +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class ShowOnAppLaunchOptionPrefsDataStore @Inject constructor( + @ShowOnAppLaunch private val store: DataStore, +) : ShowOnAppLaunchOptionDataStore { + + override var showOnAppLaunchTabId: String? = null + private set + + override val optionFlow: Flow = store.data.map { preferences -> + preferences[intPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_OPTION)]?.let { optionId -> + when (val option = ShowOnAppLaunchOption.mapToOption(optionId)) { + LastOpenedTab, + NewTabPage, + -> option + is SpecificPage -> { + val url = preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL)]!! + val resolvedUrl = preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL)] + SpecificPage(url, resolvedUrl) + } + } + } ?: LastOpenedTab + } + + override val specificPageUrlFlow: Flow = store.data.map { preferences -> + preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL)] ?: DEFAULT_SPECIFIC_PAGE_URL + } + + override suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) { + store.edit { preferences -> + preferences[intPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_OPTION)] = showOnAppLaunchOption.id + + if (showOnAppLaunchOption is SpecificPage) { + preferences.setShowOnAppLaunch(showOnAppLaunchOption.url) + preferences.remove(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL)) + showOnAppLaunchTabId = null + } + } + } + + override fun setShowOnAppLaunchTabId(tabId: String) { + showOnAppLaunchTabId = tabId + } + + override suspend fun setSpecificPageUrl(url: String) { + store.edit { preferences -> + preferences.setShowOnAppLaunch(url) + } + } + + override suspend fun setResolvedPageUrl(url: String) { + store.edit { preferences -> + preferences.setShowOnAppLaunchResolvedUrl(url) + } + } + + private fun MutablePreferences.setShowOnAppLaunch(url: String) { + set(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL), url) + } + + private fun MutablePreferences.setShowOnAppLaunchResolvedUrl(url: String) { + set(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL), url) + } + + companion object { + private const val KEY_SHOW_ON_APP_LAUNCH_OPTION = "SHOW_ON_APP_LAUNCH_OPTION" + private const val KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL = "SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL" + private const val KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL = "SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index c8adb9505206..4805ee54cbc6 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -144,6 +144,10 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_PRIVATE_SEARCH_MORE_SEARCH_SETTINGS_PRESSED("ms_private_search_more_search_settings_pressed"), SETTINGS_COOKIE_POPUP_PROTECTION_PRESSED("ms_cookie_popup_protection_setting_pressed"), SETTINGS_FIRE_BUTTON_PRESSED("ms_fire_button_setting_pressed"), + SETTINGS_GENERAL_APP_LAUNCH_PRESSED("m_settings_general_app_launch_pressed"), + SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED("m_settings_general_app_launch_last_opened_tab_selected"), + SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED("m_settings_general_app_launch_new_tab_page_selected"), + SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED("m_settings_general_app_launch_specific_page_selected"), SURVEY_CTA_SHOWN(pixelName = "mus_cs"), SURVEY_CTA_DISMISSED(pixelName = "mus_cd"), diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 70f08a2dff60..0338a282c400 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -179,6 +179,8 @@ class TabDataRepository @Inject constructor( } } + override suspend fun getTabId(url: String): String? = tabsDao.selectTabByUrl(url) + override suspend fun setIsUserNew(isUserNew: Boolean) { if (tabSwitcherDataStore.data.first().userState == UserState.UNKNOWN) { val userState = if (isUserNew) UserState.NEW else UserState.EXISTING @@ -298,6 +300,9 @@ class TabDataRepository @Inject constructor( siteData.clear() } + override suspend fun getSelectedTab(): TabEntity? = + withContext(dispatchers.io()) { tabsDao.selectedTab() } + override suspend fun select(tabId: String) { databaseExecutor().scheduleDirect { val selection = TabSelectionEntity(tabId = tabId) diff --git a/app/src/main/res/layout/activity_general_settings.xml b/app/src/main/res/layout/activity_general_settings.xml index 1677248f89e5..698f152bc700 100644 --- a/app/src/main/res/layout/activity_general_settings.xml +++ b/app/src/main/res/layout/activity_general_settings.xml @@ -69,6 +69,17 @@ app:secondaryText="@string/accessibilityVoiceSearchSubtitle" app:showSwitch="true" /> + + + + diff --git a/app/src/main/res/layout/activity_show_on_app_launch_setting.xml b/app/src/main/res/layout/activity_show_on_app_launch_setting.xml new file mode 100644 index 000000000000..bc42e01d64c0 --- /dev/null +++ b/app/src/main/res/layout/activity_show_on_app_launch_setting.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index b0e9757a2bdd..82b1ef4beb1e 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -811,4 +811,10 @@ Най-горе Отдолу + + Показване при стартиране на приложението + Последно отворен раздел + Страница с нов раздел + Конкретна страница + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ed937aa9ced7..2881a8d89b96 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -815,4 +815,10 @@ Nahoru Dole + + Zobrazit při spuštění aplikace + Naposledy otevřená karta + Stránka Nová karta + Konkrétní stránka + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 607be923a46b..4b691ab38862 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -811,4 +811,10 @@ Top Nederst + + Vis ved app-start + Sidst åbnede fane + Ny faneside + Specifik side + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 674e1d021ea7..7df22371ecbc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -811,4 +811,10 @@ Nach oben Unten + + Beim App-Start anzeigen + Zuletzt geöffneter Tab + Neue Tab-Seite + Bestimmte Seite + \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e1fee4e88f50..5240e9eef78d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -811,4 +811,10 @@ Κορυφή Κάτω μέρος + + Εμφάνιση στην Εκκίνηση εφαρμογής + Τελευταία καρτέλα που άνοιξε + Σελίδα νέας καρτέλας + Συγκεκριμένη σελίδα + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b120de8f9b38..3306e8ef0199 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -811,4 +811,10 @@ Arriba Inferior + + Mostrar al abrir la aplicación + Última pestaña abierta + Página de nueva pestaña + Página específica + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 61e2d0ca966f..9c46215d5951 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -811,4 +811,10 @@ Tipp All + + Kuva rakenduse käivitamisel + Viimati avatud vahekaart + Uue vahekaardi leht + Konkreetne lehekülg + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f5299b26f811..a7fe8b6158ed 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -811,4 +811,10 @@ Ylös Alareuna + + Näytä sovelluksen käynnistyksen yhteydessä + Viimeksi avattu -välilehti + Uusi välilehti -sivu + Tietty sivu + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 876ae50c22c1..a7785e38d138 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -811,4 +811,10 @@ Haut de page En bas + + Afficher au lancement de l\'application + Dernier onglet ouvert + Nouvelle page d\'onglet + Page spécifique + \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 4f20c914dccf..fd77cc12603d 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -815,4 +815,10 @@ Vrh Dno + + Prikaži pri pokretanju aplikacije + Posljednja otvorena kartica + Nova stranica kartice + Specifična stranica + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2d62d7e6158c..a3b8d66c2ade 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -811,4 +811,10 @@ Fel Alul + + Megjelenítés az alkalmazás indításakor + Utoljára megnyitott lap + „Új lap” oldal + Speciális oldal + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 57e6ec6f113c..c9bc92394bbc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -811,4 +811,10 @@ Inizio Parte inferiore + + Mostra all\'avvio dell\'app + Ultima scheda aperta + Pagina Nuova scheda + Pagina specifica + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 46ca48752278..c486898d3899 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -815,4 +815,10 @@ Viršus Apačia + + Rodyti paleidus programą + Paskutinį kartą atidarytas skirtukas + Naujas skirtuko puslapis + Konkretus puslapis + \ No newline at end of file diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index b47ce2ae99fc..c34ba319d874 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -813,4 +813,10 @@ Populārākie Apakšā + + Rādīt lietotnes palaišanas laikā + Pēdējā atvērtā cilne + Jaunas cilnes lapa + Konkrēta lapa + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 2a1460b7a9bc..f2bf970c846a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -811,4 +811,10 @@ Topp Nederst + + Vis ved lansering av appen + Sist åpnet fane + Ny faneside + Spesifikk side + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d0e1aa623cde..ad23f880340b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -811,4 +811,10 @@ Boven Onderkant + + Weergeven bij het starten van de app + Laatst geopende tabblad + Nieuwe tabbladpagina + Specifieke pagina + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0bf30bba0ab3..9285f34da18a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -815,4 +815,10 @@ Do góry Dół + + Pokaż przy uruchomieniu aplikacji + Ostatnio otwarta karta + Strona nowej karty + Określona strona + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5ac7df500cf3..715ae9406401 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -811,4 +811,10 @@ Topo Parte inferior + + Mostrar ao abrir a aplicação + Último separador aberto + Nova página de separador + Página específica + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ac29ac240723..5dc535f896fd 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -813,4 +813,10 @@ Sus Partea de jos + + Afișează la lansarea aplicației + Ultima filă deschisă + Filă nouă + Pagină specifică + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 87c4f4fb0606..b30b9e5785a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -815,4 +815,10 @@ Вверх Внизу + + Показывать при запуске приложения + Последняя открытая вкладка + Страница новой вкладки + Конкретная страница + \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 1607c3f51d5c..662b70f26b20 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -815,4 +815,10 @@ Hore Spodná časť + + Zobraziť pri spustení aplikácie + Naposledy otvorená karta + Stránka na novej karte + Špecifická stránka + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index b2134e664954..ce3f63ae6e86 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -815,4 +815,10 @@ Vrh Spodaj + + Pokaži ob zagonu aplikacije + Zadnji odprt zavihek + Stran z novim zavihkom + Določena stran + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 86953f848626..14a8c18d6119 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -811,4 +811,10 @@ Topp Botten + + Visa vid app-start + Senast öppnade flik + Ny fliksida + Specifik sida + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 169394b55c35..3cb4607d2996 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -811,4 +811,10 @@ Başa dön Alt + + Uygulama Başlatıldığında Göster + Son Açılan Sekme + Yeni Sekme Sayfası + Belirli Bir Sayfa + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f40e7fd16de..f4635a04ddbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -810,4 +810,10 @@ Top Bottom + + Show on App Launch + Last Opened Tab + New Tab Page + Specific Page + \ No newline at end of file diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index acaab6d828d8..5eda04d9d419 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.fire.DataClearer +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder @@ -44,7 +46,12 @@ import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class BrowserViewModelTest { @@ -52,34 +59,29 @@ class BrowserViewModelTest { @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() - @get:Rule - var coroutinesTestRule = CoroutineTestRule() + @get:Rule var coroutinesTestRule = CoroutineTestRule() - @Mock - private lateinit var mockCommandObserver: Observer + @Mock private lateinit var mockCommandObserver: Observer private val commandCaptor = argumentCaptor() - @Mock - private lateinit var mockTabRepository: TabRepository + @Mock private lateinit var mockTabRepository: TabRepository + + @Mock private lateinit var mockOmnibarEntryConverter: OmnibarEntryConverter + + @Mock private lateinit var mockAutomaticDataClearer: DataClearer - @Mock - private lateinit var mockOmnibarEntryConverter: OmnibarEntryConverter + @Mock private lateinit var mockAppEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder - @Mock - private lateinit var mockAutomaticDataClearer: DataClearer + @Mock private lateinit var mockAppEnjoymentPromptEmitter: AppEnjoymentPromptEmitter - @Mock - private lateinit var mockAppEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder + @Mock private lateinit var mockPixel: Pixel - @Mock - private lateinit var mockAppEnjoymentPromptEmitter: AppEnjoymentPromptEmitter + @Mock private lateinit var mockDefaultBrowserDetector: DefaultBrowserDetector - @Mock - private lateinit var mockPixel: Pixel + @Mock private lateinit var showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler - @Mock - private lateinit var mockDefaultBrowserDetector: DefaultBrowserDetector + private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) private lateinit var testee: BrowserViewModel @@ -93,17 +95,7 @@ class BrowserViewModelTest { configureSkipUrlConversionInNewTabState(enabled = true) - testee = BrowserViewModel( - tabRepository = mockTabRepository, - queryUrlConverter = mockOmnibarEntryConverter, - dataClearer = mockAutomaticDataClearer, - appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, - appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, - defaultBrowserDetector = mockDefaultBrowserDetector, - dispatchers = coroutinesTestRule.testDispatcherProvider, - pixel = mockPixel, - skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, - ) + initTestee() testee.command.observeForever(mockCommandObserver) @@ -276,6 +268,40 @@ class BrowserViewModelTest { verify(mockTabRepository).select(tabId) } + @Test + fun whenHandleShowOnAppLaunchCalledThenNoTabIsAddedByDefault() = runTest { + testee.handleShowOnAppLaunchOption() + + verify(mockTabRepository, never()).add() + verify(mockTabRepository, never()).addFromSourceTab(url = any(), skipHome = any(), sourceTabId = any()) + verify(mockTabRepository, never()).addDefaultTab() + } + + @Test + fun whenShowOnAppLaunchFeatureToggleIsOnThenShowOnAppLaunchHandled() = runTest { + fakeShowOnAppLaunchFeatureToggle.self().setRawStoredState(State(enable = true)) + + testee.handleShowOnAppLaunchOption() + + verify(showOnAppLaunchOptionHandler).handleAppLaunchOption() + } + + private fun initTestee() { + testee = BrowserViewModel( + tabRepository = mockTabRepository, + queryUrlConverter = mockOmnibarEntryConverter, + dataClearer = mockAutomaticDataClearer, + appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, + appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, + defaultBrowserDetector = mockDefaultBrowserDetector, + dispatchers = coroutinesTestRule.testDispatcherProvider, + pixel = mockPixel, + skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, + showOnAppLaunchFeature = fakeShowOnAppLaunchFeatureToggle, + showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler, + ) + } + private fun configureSkipUrlConversionInNewTabState(enabled: Boolean) { skipUrlConversionOnNewTabFeature.self().setRawStoredState(State(enable = enabled)) } diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt index a4aaa1dc9cc2..0f28c3fefddc 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt @@ -19,14 +19,24 @@ package com.duckduckgo.app.generalsettings import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.duckduckgo.app.FakeSettingsDataStore +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command.LaunchShowOnAppLaunchScreen +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_PRESSED import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.voice.api.VoiceSearchAvailability import com.duckduckgo.voice.impl.VoiceSearchPixelNames import com.duckduckgo.voice.store.VoiceSearchRepository import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -49,6 +59,10 @@ internal class GeneralSettingsViewModelTest { private lateinit var fakeAppSettingsDataStore: FakeSettingsDataStore + private lateinit var fakeShowOnAppLaunchOptionDataStore: FakeShowOnAppLaunchOptionDataStore + + private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) + @Mock private lateinit var mockPixel: Pixel @@ -64,25 +78,20 @@ internal class GeneralSettingsViewModelTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - val dispatcherProvider = coroutineTestRule.testDispatcherProvider + private val dispatcherProvider = coroutineTestRule.testDispatcherProvider @Before fun before() { MockitoAnnotations.openMocks(this) - runTest { + runBlocking { whenever(mockHistory.isHistoryUserEnabled()).thenReturn(true) + whenever(mockHistory.isHistoryFeatureAvailable()).thenReturn(false) fakeAppSettingsDataStore = FakeSettingsDataStore() - testee = GeneralSettingsViewModel( - fakeAppSettingsDataStore, - mockPixel, - mockHistory, - mockVoiceSearchAvailability, - mockVoiceSearchRepository, - dispatcherProvider, - ) + fakeShowOnAppLaunchOptionDataStore = FakeShowOnAppLaunchOptionDataStore() + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) } } @@ -95,6 +104,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOnThenDataStoreIsUpdated() { + initTestee() + testee.onAutocompleteSettingChanged(true) assertTrue(fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled) @@ -102,6 +113,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOffThenDataStoreIsUpdated() { + initTestee() + testee.onAutocompleteSettingChanged(false) assertFalse(fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled) @@ -109,6 +122,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOffThenRecentlyVisitedSitesIsUpdated() = runTest { + initTestee() + testee.onAutocompleteSettingChanged(false) verify(mockHistory).setHistoryUserEnabled(false) @@ -116,6 +131,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteRecentlyVisitedSitesSwitchedOnThenHistoryUpdated() = runTest { + initTestee() + testee.onAutocompleteRecentlyVisitedSitesSettingChanged(true) verify(mockHistory).setHistoryUserEnabled(true) @@ -123,6 +140,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteRecentlyVisitedSitesSwitchedOffThenHistoryUpdated() = runTest { + initTestee() + whenever(mockHistory.isHistoryUserEnabled()).thenReturn(false) testee.onAutocompleteRecentlyVisitedSitesSettingChanged(false) @@ -132,10 +151,13 @@ internal class GeneralSettingsViewModelTest { @Test fun whenVoiceSearchEnabledThenViewStateEmitted() = runTest { fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled = true + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) whenever(mockVoiceSearchAvailability.isVoiceSearchAvailable).thenReturn(true) val viewState = defaultViewState() + initTestee() + testee.onVoiceSearchChanged(true) testee.viewState.test { @@ -146,33 +168,151 @@ internal class GeneralSettingsViewModelTest { @Test fun whenVoiceSearchEnabledThenSettingsUpdated() = runTest { + initTestee() + testee.onVoiceSearchChanged(true) + verify(mockVoiceSearchRepository).setVoiceSearchUserEnabled(true) } @Test fun whenVoiceSearchDisabledThenSettingsUpdated() = runTest { + initTestee() + testee.onVoiceSearchChanged(false) verify(mockVoiceSearchRepository).setVoiceSearchUserEnabled(false) } @Test fun whenVoiceSearchEnabledThenFirePixel() = runTest { + initTestee() + testee.onVoiceSearchChanged(true) verify(mockPixel).fire(VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_ON) } @Test fun whenVoiceSearchDisabledThenFirePixel() = runTest { + initTestee() + testee.onVoiceSearchChanged(false) verify(mockPixel).fire(VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_OFF) } + @Test + fun whenShowOnAppLaunchClickedThenLaunchShowOnAppLaunchScreenCommandEmitted() = runTest { + initTestee() + + testee.onShowOnAppLaunchButtonClick() + + testee.commands.test { + assertEquals(LaunchShowOnAppLaunchScreen, awaitItem()) + } + } + + @Test + fun whenShowOnAppLaunchSetToLastOpenedTabThenShowOnAppLaunchOptionIsLastOpenedTab() = runTest { + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + initTestee() + + testee.viewState.test { + assertEquals(LastOpenedTab, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchSetToNewTabPageThenShowOnAppLaunchOptionIsNewTabPage() = runTest { + initTestee() + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(NewTabPage) + + testee.viewState.test { + assertEquals(NewTabPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchSetToSpecificPageThenShowOnAppLaunchOptionIsSpecificPage() = runTest { + val specificPage = SpecificPage("example.com") + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(specificPage) + + initTestee() + + testee.viewState.test { + assertEquals(specificPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchUpdatedThenViewStateIsUpdated() = runTest { + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + initTestee() + + testee.viewState.test { + awaitItem() + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(NewTabPage) + + assertEquals(NewTabPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchClickedThenPixelFiredEmitted() = runTest { + initTestee() + + testee.onShowOnAppLaunchButtonClick() + + verify(mockPixel).fire(SETTINGS_GENERAL_APP_LAUNCH_PRESSED) + } + + @Test + fun whenLaunchedThenShowOnAppLaunchIsNotVisibleByDefault() = runTest { + initTestee() + + testee.viewState.test { + val state = awaitItem() + + assertTrue(!state!!.isShowOnAppLaunchOptionVisible) + } + } + + @Test + fun whenShowOnAppLaunchFeatureIsDisabledThenIsShowOnAppLaunchOptionIsVisible() = runTest { + fakeShowOnAppLaunchFeatureToggle.self().setRawStoredState(Toggle.State(enable = true)) + + initTestee() + + testee.viewState.test { + val state = awaitItem() + + assertTrue(state!!.isShowOnAppLaunchOptionVisible) + } + } + private fun defaultViewState() = GeneralSettingsViewModel.ViewState( autoCompleteSuggestionsEnabled = true, autoCompleteRecentlyVisitedSitesSuggestionsUserEnabled = true, storeHistoryEnabled = false, showVoiceSearch = false, voiceSearchEnabled = false, + isShowOnAppLaunchOptionVisible = fakeShowOnAppLaunchFeatureToggle.self().isEnabled(), + showOnAppLaunchSelectedOption = LastOpenedTab, ) + + private fun initTestee() { + testee = GeneralSettingsViewModel( + fakeAppSettingsDataStore, + mockPixel, + mockHistory, + mockVoiceSearchAvailability, + mockVoiceSearchRepository, + dispatcherProvider, + fakeShowOnAppLaunchFeatureToggle, + fakeShowOnAppLaunchOptionDataStore, + ) + } } diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt new file mode 100644 index 000000000000..d663c75048eb --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -0,0 +1,855 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.model.TabSwitcherData +import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchOptionHandlerImplTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + + private lateinit var fakeDataStore: ShowOnAppLaunchOptionDataStore + private lateinit var fakeTabRepository: TabRepository + private lateinit var testee: ShowOnAppLaunchOptionHandler + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore() + fakeTabRepository = FakeTabRepository() + testee = + ShowOnAppLaunchOptionHandlerImpl(dispatcherProvider, fakeDataStore, fakeTabRepository) + } + + @Test + fun whenOptionIsLastTabOpenedThenNoTabIsAdded() = runTest { + fakeDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.isEmpty()) + } + } + + @Test + fun whenOptionIsNewTabPageOpenedThenNewTabPageIsAdded() = runTest { + fakeDataStore.setShowOnAppLaunchOption(NewTabPage) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == "") + } + } + + @Test + fun whenOptionIsSpecificUrlThenTabIsAdded() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabDoesNotExistThenTabIdIsStored() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tab = awaitItem() + awaitComplete() + + assertTrue(fakeDataStore.showOnAppLaunchTabId == tab.first().tabId) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabExistsThenExistingTabIdIsStored() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + val existingTabId = fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + awaitItem() + awaitComplete() + + assertTrue(fakeDataStore.showOnAppLaunchTabId == existingTabId) + } + } + + @Test + fun whenOptionIsSpecificUrlWithSubdomainThenTabIsAdded() = runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndUrlIsHttpThenTabIsAdded() = runTest { + val url = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithSubdomainAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpAndHttpsTabAlreadyAddedThenTabIsNotAdded() = runTest { + val url = "http://example.com/" + val httpsUrl = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(httpsUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == httpsUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpsAndHttpTabAlreadyAddedThenTabIsNotAdded() = runTest { + val url = "https://example.com/" + val httpUrl = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(httpUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == httpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDomainOnlyAndTabAlreadyAddedWithSchemeAndSubdomainThenTabIsNotAdded() = + runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathThenTabIsAdded() = runTest { + val queryUrl = "https://example.com/article/1234" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathAndTabAlreadyAddedThenTabIsNotAdded() = runTest { + val queryUrl = "https://example.com/article/1234" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + fakeTabRepository.add(queryUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoPathAndTabExistsWithPathThenTabIsAdded() = runTest { + val url = "http://example.com/" + val pathUrl = "https://example.com/article/1234/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(pathUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathAndTabExistsWithoutPathThenTabIsAdded() = runTest { + val url = "https://example.com/article/1234/" + val pathUrl = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(pathUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentPathThenTabIsAdded() = runTest { + val url1 = "https://example.com/path1" + val url2 = "https://example.com/path2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithWWWSubdomainAndDifferentPathThenTabIsAdded() = runTest { + val url1 = "https://www.example.com/path1" + val url2 = "https://www.example.com/path2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWWWSubdomainAndTabExistsWithWWWSubdomainThenTabIsAdded() = runTest { + val url1 = "https://blog.example.com/" + val url2 = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoSubdomainAndTabExistsWithWWWSubdomainThenTabIsNotAdded() = runTest { + val url1 = "https://example.com/" + val url2 = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url2) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringThenTabIsAdded() = runTest { + val queryUrl = "https://example.com/?query=1" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentQueryParameterThenTabIsAdded() = runTest { + val url1 = "https://example.com/path?query1=value1" + val url2 = "https://example.com/path?query2=value2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithFragmentThenTabIsAdded() = runTest { + val fragmentUrl = "https://example.com/#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(fragmentUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == fragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentFragmentThenTabIsAdded() = runTest { + val url1 = "https://example.com/path?query=value#fragment1" + val url2 = "https://example.com/path?query=value#fragment2" + + fakeTabRepository.add(url1) + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url2)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url2) + } + } + + @Test + fun whenOptionIsSpecificUrlWithFragmentAndIsAddedThenTabIsNotAdded() = runTest { + val fragmentUrl = "https://example.com/#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(fragmentUrl)) + fakeTabRepository.add(fragmentUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == fragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringAndFragmentThenTabIsAdded() = runTest { + val queryFragmentUrl = "https://example.com/?query=1#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryFragmentUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryFragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringAndFragmentAndIsAddedThenTabIsNotAdded() = runTest { + val queryFragmentUrl = "https://example.com/?query=1#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryFragmentUrl)) + fakeTabRepository.add(queryFragmentUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryFragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonHttpOrHttpsProtocolAndNotAddedThenTabIsAdded() = runTest { + val ftpUrl = "ftp://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(ftpUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == ftpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonHttpOrHttpsProtocolAndAddedThenTabIsNotAdded() = runTest { + val ftpUrl = "ftp://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(ftpUrl)) + fakeTabRepository.add(ftpUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == ftpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlThenTabIsAdded() = runTest { + val url = "https://www.example.com/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlAndTabMatchesResolvedUrlThenTabIsNotAdded() = + runTest { + val url = "https://example.com/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + fakeTabRepository.add(resolvedUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == resolvedUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlAndTabMatchesBothUrlsThenTabIsNotAdded() = runTest { + val url = "https://www.example.co.uk/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + fakeTabRepository.add(resolvedUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == resolvedUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWwwSubdomainThenTabIsAdded() = runTest { + val url = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWwwSubdomainAndTabExistsThenTabIsNotAdded() = runTest { + val url = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoSubdomainAndTabWithDifferentSubdomainExistsThenTabIsAdded() = + runTest { + val noSubdomainUrl = "https://example.com/" + val subdomainUrl = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(noSubdomainUrl)) + fakeTabRepository.add(subdomainUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == noSubdomainUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentPortThenTabIsAdded() = runTest { + val url = "https://example.com:8080/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + private class FakeTabRepository : TabRepository { + + private val tabs = mutableMapOf() + + override suspend fun select(tabId: String) = Unit + + override suspend fun add( + url: String?, + skipHome: Boolean, + ): String { + tabs[tabs.size + 1] = url ?: "" + return tabs.size.toString() + } + + override suspend fun getTabId(url: String): String? { + return tabs.values.firstOrNull { it.contains(url) } + } + + override val flowTabs: Flow> = flowOf(tabs).map { + it.map { (id, url) -> TabEntity(tabId = id.toString(), url = url, position = id) } + } + + override val liveTabs: LiveData> + get() = TODO("Not yet implemented") + override val childClosedTabs: SharedFlow + get() = TODO("Not yet implemented") + override val flowDeletableTabs: Flow> + get() = TODO("Not yet implemented") + override val liveSelectedTab: LiveData + get() = TODO("Not yet implemented") + override val tabSwitcherData: Flow + get() = TODO("Not yet implemented") + + override suspend fun addDefaultTab(): String { + TODO("Not yet implemented") + } + + override suspend fun addFromSourceTab( + url: String?, + skipHome: Boolean, + sourceTabId: String, + ): String { + TODO("Not yet implemented") + } + + override suspend fun addNewTabAfterExistingTab( + url: String?, + tabId: String, + ) { + TODO("Not yet implemented") + } + + override suspend fun update( + tabId: String, + site: Site?, + ) { + TODO("Not yet implemented") + } + + override suspend fun updateTabPosition( + from: Int, + to: Int, + ) { + TODO("Not yet implemented") + } + + override fun retrieveSiteData(tabId: String): MutableLiveData { + TODO("Not yet implemented") + } + + override suspend fun delete(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun markDeletable(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun undoDeletable(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun purgeDeletableTabs() { + TODO("Not yet implemented") + } + + override suspend fun getDeletableTabIds(): List { + TODO("Not yet implemented") + } + + override suspend fun deleteTabAndSelectSource(tabId: String) { + TODO("Not yet implemented") + } + + override suspend fun deleteAll() { + TODO("Not yet implemented") + } + + override suspend fun getSelectedTab(): TabEntity? { + TODO("Not yet implemented") + } + + override fun updateTabPreviewImage( + tabId: String, + fileName: String?, + ) { + TODO("Not yet implemented") + } + + override fun updateTabFavicon( + tabId: String, + fileName: String?, + ) { + TODO("Not yet implemented") + } + + override suspend fun selectByUrlOrNewTab(url: String) { + TODO("Not yet implemented") + } + + override suspend fun setIsUserNew(isUserNew: Boolean) { + TODO("Not yet implemented") + } + + override suspend fun setTabLayoutType(layoutType: LayoutType) { + TODO("Not yet implemented") + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt new file mode 100644 index 000000000000..07c10d5e99a3 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchReporterPluginTest { + + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + private lateinit var testee: ShowOnAppLaunchStateReporterPlugin + private lateinit var fakeDataStore: ShowOnAppLaunchOptionDataStore + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore(ShowOnAppLaunchOption.LastOpenedTab) + + testee = ShowOnAppLaunchStateReporterPlugin(dispatcherProvider, fakeDataStore) + } + + @Test + fun whenOptionIsSetToLastOpenedPageThenShouldReturnDailyPixelValue() = runTest { + fakeDataStore.setShowOnAppLaunchOption(ShowOnAppLaunchOption.LastOpenedTab) + val result = testee.featureStateParams() + assertEquals("last_opened_tab", result[PixelParameter.LAUNCH_SCREEN]) + } + + @Test + fun whenOptionIsSetToNewTabPageThenShouldReturnDailyPixelValue() = runTest { + fakeDataStore.setShowOnAppLaunchOption(ShowOnAppLaunchOption.NewTabPage) + val result = testee.featureStateParams() + assertEquals("new_tab_page", result[PixelParameter.LAUNCH_SCREEN]) + } + + @Test + fun whenOptionIsSetToSpecificPageThenShouldReturnDailyPixelValue() = runTest { + val specificPage = ShowOnAppLaunchOption.SpecificPage("example.com") + fakeDataStore.setShowOnAppLaunchOption(specificPage) + val result = testee.featureStateParams() + assertEquals("specific_page", result[PixelParameter.LAUNCH_SCREEN]) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt new file mode 100644 index 000000000000..45a87f1c1ec9 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore.Companion.DEFAULT_SPECIFIC_PAGE_URL +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchUrlConverterImplTest { + + private val urlConverter = ShowOnAppLaunchUrlConverterImpl() + + @Test + fun whenUrlIsNullThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl(null) + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlIsEmptyThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl("") + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlIsBlankThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl(" ") + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlHasNoSchemeThenHttpSchemeIsAdded() { + val result = urlConverter.convertUrl("www.example.com") + assertEquals("http://www.example.com", result) + } + + @Test + fun whenUrlHasNoSchemeAndSubdomainThenHttpSchemeIsAdded() { + val result = urlConverter.convertUrl("example.com") + assertEquals("http://example.com", result) + } + + @Test + fun whenUrlDoesNotHaveAPathThenForwardSlashIsAdded() { + val result = urlConverter.convertUrl("https://www.example.com") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasASchemeThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasDifferentSchemeThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("ftp://www.example.com/") + assertEquals("ftp://www.example.com/", result) + } + + @Test + fun whenUrlHasSpecialCharactersThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/path?query=param&another=param") + assertEquals("https://www.example.com/path?query=param&another=param", result) + } + + @Test + fun whenUrlHasPortThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com:8080/") + assertEquals("https://www.example.com:8080/", result) + } + + @Test + fun whenUrlHasPathAndQueryParametersThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/path/to/resource?query=param") + assertEquals("https://www.example.com/path/to/resource?query=param", result) + } + + @Test + fun whenUrlHasUppercaseProtocolThenShouldLowercaseProtocol() { + val result = urlConverter.convertUrl("HTTPS://www.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseSubdomainThenShouldLowercaseSubdomain() { + val result = urlConverter.convertUrl("https://WWW.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseDomainThenShouldLowercaseDomain() { + val result = urlConverter.convertUrl("https://www.EXAMPLE.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseTopLevelDomainThenShouldLowercaseTopLevelDomain() { + val result = urlConverter.convertUrl("https://www.example.COM/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasMixedCaseThenOnlyProtocolSubdomainDomainAndTldAreLowercased() { + val result = urlConverter.convertUrl("HTTPS://WWW.EXAMPLE.COM/Path?Query=Param#Fragment") + assertEquals("https://www.example.com/Path?Query=Param#Fragment", result) + } + + @Test + fun whenUrlIsNotAValidUrlReturnsInvalidUrlWithHttpScheme() { + val result = urlConverter.convertUrl("example") + assertEquals("http://example", result) + } + + @Test + fun whenUrlHasADifferentSchemeThenSameUrlReturned() { + val result = urlConverter.convertUrl("ftp://example.com/") + assertEquals("ftp://example.com/", result) + } + + @Test + fun whenUrlHasADifferentSchemeAndNoTrailingSlashThenTrailingSlashAdded() { + val result = urlConverter.convertUrl("ftp://example.com") + assertEquals("ftp://example.com/", result) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt new file mode 100644 index 000000000000..dc8a0af55ac0 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.fakes.FakePixel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ShowOnAppLaunchViewModelTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var testee: ShowOnAppLaunchViewModel + private lateinit var fakeDataStore: FakeShowOnAppLaunchOptionDataStore + private lateinit var fakePixel: FakePixel + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore(LastOpenedTab) + fakePixel = FakePixel() + testee = ShowOnAppLaunchViewModel(dispatcherProvider, fakeDataStore, FakeUrlConverter(), fakePixel) + } + + @Test + fun whenViewModelInitializedThenInitialStateIsCorrect() = runTest { + testee.viewState.test { + val initialState = awaitItem() + assertEquals(LastOpenedTab, initialState.selectedOption) + assertEquals("https://duckduckgo.com", initialState.specificPageUrl) + } + } + + @Test + fun whenShowOnAppLaunchOptionChangedThenStateIsUpdated() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(NewTabPage, updatedState.selectedOption) + } + } + + @Test + fun whenSpecificPageUrlSetThenStateIsUpdated() = runTest { + val newUrl = "https://example.com" + + testee.setSpecificPageUrl(newUrl) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(newUrl, updatedState.specificPageUrl) + } + } + + @Test + fun whenMultipleOptionsChangedThenStateIsUpdatedCorrectly() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.onShowOnAppLaunchOptionChanged(LastOpenedTab) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(LastOpenedTab, updatedState.selectedOption) + } + } + + @Test + fun whenOptionChangedToLastOpenedPageThenLastOpenedPageIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.onShowOnAppLaunchOptionChanged(LastOpenedTab) + assertEquals(2, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED.pixelName, fakePixel.firedPixels.last()) + } + + @Test + fun whenOptionChangedToNewTabPageThenNewTabPagePixelIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + assertEquals(1, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED.pixelName, fakePixel.firedPixels[0]) + } + + @Test + fun whenOptionChangedToSpecificPageThenSpecificPixelIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(SpecificPage("https://example.com")) + assertEquals(1, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED.pixelName, fakePixel.firedPixels[0]) + } + + private class FakeUrlConverter : UrlConverter { + + override fun convertUrl(url: String?): String { + return url ?: "https://duckduckgo.com" + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt new file mode 100644 index 000000000000..e24ae2050472 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull + +class FakeShowOnAppLaunchOptionDataStore(defaultOption: ShowOnAppLaunchOption? = null) : ShowOnAppLaunchOptionDataStore { + + override var showOnAppLaunchTabId: String? = null + private set + + private var currentOptionStateFlow = MutableStateFlow(defaultOption) + + private var currentSpecificPageUrl = MutableStateFlow("https://duckduckgo.com") + + override val optionFlow: Flow = currentOptionStateFlow.asStateFlow().filterNotNull() + + override val specificPageUrlFlow: Flow = currentSpecificPageUrl.asStateFlow() + + override suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) { + currentOptionStateFlow.value = showOnAppLaunchOption + } + + override suspend fun setSpecificPageUrl(url: String) { + currentSpecificPageUrl.value = url + } + + override suspend fun setResolvedPageUrl(url: String) { + TODO("Not yet implemented") + } + + override fun setShowOnAppLaunchTabId(tabId: String) { + showOnAppLaunchTabId = tabId + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt new file mode 100644 index 000000000000..dcb1c8dec27a --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchPrefsDataStoreTest { + + @get:Rule val coroutineRule = CoroutineTestRule() + + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private val dataStoreFile = context.preferencesDataStoreFile("show_on_app_launch") + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineRule.testScope, + produceFile = { dataStoreFile }, + ) + + private val testee: ShowOnAppLaunchOptionDataStore = + ShowOnAppLaunchOptionPrefsDataStore(testDataStore) + + @After + fun after() { + dataStoreFile.delete() + } + + @Test + fun whenOptionIsNullThenShouldReturnLastOpenedPage() = runTest { + assertEquals(LastOpenedTab, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToLastOpenedPageThenShouldReturnLastOpenedPage() = runTest { + testee.setShowOnAppLaunchOption(LastOpenedTab) + assertEquals(LastOpenedTab, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToNewTabPageThenShouldReturnNewTabPage() = runTest { + testee.setShowOnAppLaunchOption(NewTabPage) + assertEquals(NewTabPage, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToSpecificPageThenShouldReturnSpecificPage() = runTest { + val specificPage = SpecificPage("example.com") + + testee.setShowOnAppLaunchOption(specificPage) + assertEquals(specificPage, testee.optionFlow.first()) + } + + @Test + fun whenSpecificPageIsNullThenShouldReturnDefaultUrl() = runTest { + assertEquals("https://duckduckgo.com/", testee.specificPageUrlFlow.first()) + } + + @Test + fun whenSpecificPageUrlIsSetThenShouldReturnSpecificPageUrl() = runTest { + testee.setSpecificPageUrl("example.com") + assertEquals("example.com", testee.specificPageUrlFlow.first()) + } + + @Test + fun whenOptionIsChangedThenNewOptionEmitted() = runTest { + testee.optionFlow.test { + val defaultOption = awaitItem() + + assertEquals(LastOpenedTab, defaultOption) + + testee.setShowOnAppLaunchOption(NewTabPage) + + assertEquals(NewTabPage, awaitItem()) + + testee.setShowOnAppLaunchOption(SpecificPage("example.com")) + + assertEquals(SpecificPage("example.com"), awaitItem()) + } + } +} diff --git a/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt b/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt new file mode 100644 index 000000000000..ca4bbb7ec52d --- /dev/null +++ b/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.fakes + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType + +internal class FakePixel : Pixel { + + val firedPixels = mutableListOf() + + override fun fire( + pixel: PixelName, + parameters: Map, + encodedParameters: Map, + type: PixelType, + ) { + firedPixels.add(pixel.pixelName) + } + + override fun fire( + pixelName: String, + parameters: Map, + encodedParameters: Map, + type: PixelType, + ) { + firedPixels.add(pixelName) + } + + override fun enqueueFire( + pixel: PixelName, + parameters: Map, + encodedParameters: Map, + ) { + firedPixels.add(pixel.pixelName) + } + + override fun enqueueFire( + pixelName: String, + parameters: Map, + encodedParameters: Map, + ) { + firedPixels.add(pixelName) + } +} diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index a96eb9f44eb5..0e7e877c8020 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -93,6 +93,8 @@ interface TabRepository { suspend fun deleteAll() + suspend fun getSelectedTab(): TabEntity? + suspend fun select(tabId: String) fun updateTabPreviewImage( @@ -107,6 +109,8 @@ interface TabRepository { suspend fun selectByUrlOrNewTab(url: String) + suspend fun getTabId(url: String): String? + suspend fun setIsUserNew(isUserNew: Boolean) suspend fun setTabLayoutType(layoutType: LayoutType) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt similarity index 84% rename from network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt rename to common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt index 541a00fc39c8..7a9d7c63d5e5 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.networkprotection.impl.settings.geoswitching +package com.duckduckgo.common.ui.view.listitem import android.content.Context import android.util.AttributeSet @@ -26,13 +26,13 @@ import com.duckduckgo.common.ui.view.listitem.DaxListItem.ImageBackground import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.networkprotection.impl.R -import com.duckduckgo.networkprotection.impl.databinding.ViewRadioListItemBinding +import com.duckduckgo.mobile.android.R +import com.duckduckgo.mobile.android.databinding.ViewRadioListItemBinding class RadioListItem @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = com.duckduckgo.mobile.android.R.attr.twoLineListItemStyle, + defStyleAttr: Int = R.attr.twoLineListItemStyle, ) : ConstraintLayout(context, attrs, defStyleAttr) { private val binding: ViewRadioListItemBinding by viewBinding() @@ -53,8 +53,15 @@ class RadioListItem @JvmOverloads constructor( attrs, R.styleable.RadioListItem, 0, - com.duckduckgo.mobile.android.R.style.Widget_DuckDuckGo_TwoLineListItem, + R.style.Widget_DuckDuckGo_TwoLineListItem, ).apply { + if (hasValue(R.styleable.RadioListItem_android_minHeight)) { + binding.itemContainer.minHeight = + getDimensionPixelSize(R.styleable.RadioListItem_android_minHeight, resources.getDimensionPixelSize(R.dimen.oneLineItemHeight)) + } else { + binding.itemContainer.minHeight = resources.getDimensionPixelSize(R.dimen.oneLineItemHeight) + } + binding.radioButton.isChecked = getBoolean(R.styleable.RadioListItem_android_checked, false) binding.primaryText.text = getString(R.styleable.RadioListItem_primaryText) @@ -98,6 +105,7 @@ class RadioListItem @JvmOverloads constructor( } fun setClickListener(onClick: () -> Unit) { + binding.radioButton.setOnClickListener { onClick() } binding.itemContainer.setOnClickListener { onClick() } } @@ -124,4 +132,8 @@ class RadioListItem @JvmOverloads constructor( fun setTrailingIconClickListener(onClick: (View) -> Unit) { trailingIconContainer.setOnClickListener { onClick(trailingIconContainer) } } + + fun setChecked(checked: Boolean) { + radioButton.isChecked = checked + } } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt index 23b3a2152cd0..b55ab05f9063 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt @@ -71,6 +71,7 @@ interface TextInput { @DrawableRes endIconRes: Int, contentDescription: String? = null, ) + fun setSelectAllOnFocus(boolean: Boolean) fun removeEndIcon() @@ -264,6 +265,10 @@ class DaxTextInput @JvmOverloads constructor( } } + override fun setSelectAllOnFocus(boolean: Boolean) { + binding.internalEditText.setSelectAllOnFocus(boolean) + } + override fun removeEndIcon() { binding.internalInputLayout.apply { endIconMode = END_ICON_NONE diff --git a/network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml b/common/common-ui/src/main/res/layout/view_radio_list_item.xml similarity index 99% rename from network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml rename to common/common-ui/src/main/res/layout/view_radio_list_item.xml index 24b9f26e56b3..b1682a3e9ad9 100644 --- a/network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml +++ b/common/common-ui/src/main/res/layout/view_radio_list_item.xml @@ -32,6 +32,7 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/keyline_3" android:minWidth="0dp" + android:minHeight="0dp" android:padding="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml b/common/common-ui/src/main/res/values/attrs-radio-list-item.xml similarity index 96% rename from network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml rename to common/common-ui/src/main/res/values/attrs-radio-list-item.xml index 8ee6649c0be1..ad4d814b0555 100644 --- a/network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml +++ b/common/common-ui/src/main/res/values/attrs-radio-list-item.xml @@ -27,5 +27,6 @@ + \ No newline at end of file diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt index 5dbb21893018..0156af20d780 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt @@ -56,6 +56,9 @@ val Uri.isHttps: Boolean val Uri.toHttps: Uri get() = buildUpon().scheme(UrlScheme.https).build() +val Uri.isHttpOrHttps: Boolean + get() = isHttp || isHttps + val Uri.hasIpHost: Boolean get() { return baseHost?.matches(IP_REGEX) ?: false diff --git a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml index 42787d399abe..103ed48ea541 100644 --- a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml +++ b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" app:primaryText="@string/netpGeoswitchingHeaderRecommended" /> - - \ No newline at end of file diff --git a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index c8c240bee445..4511e1ab9226 100644 --- a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -63,6 +63,7 @@ interface Pixel { // Loading Bar Experiment const val LOADING_BAR_EXPERIMENT = "loading_bar_exp" + const val LAUNCH_SCREEN = "launch_screen" } object PixelValues { From 590d792eaf44343ef50c03ccdc6d2603bddb07f6 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Thu, 7 Nov 2024 14:57:56 +0000 Subject: [PATCH 04/26] Include 10.x.x.x local net traffic through VPN (#5252) Task/Issue URL: https://app.asana.com/0/1198194956794324/1208714463179856/f ### Description 10.x.x.x range (traffic) should go through the VPN when local networks are included. ### Steps to test this PR _Test_ - [x] install from this branch, launch and sign into PPro - [x] in settigns -> VPN -> VPN settings disable `Exclude local Networks` setting - [x] enable VPN - [x] verify 10.x.x.x route is included (you can do that by filtering logcat by `Adding route`) - [x] smoke test apps and browser, all should work normally - [x] in settigns -> VPN -> VPN settings enable `Exclude local Networks` setting and navigate out of VPN settings for the VPN to re-configre - [x] verify 10.x.x.x route is excluded (you can do that by filtering logcat by `Adding route`) - [x] smoke test apps and browser, all should work normally --- .../com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt index 96d10de03bca..ee6e7161833d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt @@ -90,7 +90,7 @@ internal class WgVpnRoutes { val wgVpnRoutesIncludingLocal: Map = mapOf( "0.0.0.0" to 5, "8.0.0.0" to 7, - // Excluded range: 10.0.0.0 -> 10.255.255.255 + "10.0.0.0" to 8, "11.0.0.0" to 8, "12.0.0.0" to 6, "16.0.0.0" to 4, From fa2e0796fb003b04c54f812305fb89c662daaa1d Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:23:26 +1100 Subject: [PATCH 05/26] Update content scope scripts to version 6.30.0 (#5256) Task/Issue URL: https://app.asana.com/0/488551667048375/1208721460896060/f ----- - Automated content scope scripts dependency update This PR updates the content scope scripts dependency to the latest available version and copies the necessary files. If tests have failed, see https://app.asana.com/0/1202561462274611/1203986899650836/f for further information on what to do next. - [x] All tests must pass Co-authored-by: daxmobile --- .../build/android/contentScope.js | 5004 +++++++++-------- .../android/pages/duckplayer/js/index.js | 194 +- .../android/pages/duckplayer/js/storage.js | 38 +- .../android/pages/duckplayer/js/utils.js | 28 +- package-lock.json | 18 +- package.json | 2 +- 6 files changed, 2688 insertions(+), 2596 deletions(-) diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index 97de912ee16b..dc375fdb6cd6 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -14,12 +14,13 @@ const objectDefineProperty = Object.defineProperty; const URL$1 = globalThis.URL; const Proxy$1 = globalThis.Proxy; + const hasOwnProperty = Object.prototype.hasOwnProperty; /* eslint-disable no-redeclare, no-global-assign */ /* global cloneInto, exportFunction, false */ // Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox - + let globalObj = typeof window === 'undefined' ? globalThis : window; let Error$1 = globalObj.Error; let messageSecret; @@ -27,15 +28,15 @@ // save a reference to original CustomEvent amd dispatchEvent so they can't be overriden to forge messages const OriginalCustomEvent = typeof CustomEvent === 'undefined' ? null : CustomEvent; const originalWindowDispatchEvent = typeof window === 'undefined' ? null : window.dispatchEvent.bind(window); - function registerMessageSecret (secret) { + function registerMessageSecret(secret) { messageSecret = secret; } /** * @returns {HTMLElement} the element to inject the script into */ - function getInjectionElement () { - return document.head || document.documentElement + function getInjectionElement() { + return document.head || document.documentElement; } /** @@ -43,41 +44,41 @@ * @param {string} css * @returns {HTMLLinkElement | HTMLStyleElement} */ - function createStyleElement (css) { + function createStyleElement(css) { let style; { style = document.createElement('style'); style.innerText = css; } - return style + return style; } /** * Injects a script into the page, avoiding CSP restrictions if possible. */ - function injectGlobalStyles (css) { + function injectGlobalStyles(css) { const style = createStyleElement(css); getInjectionElement().appendChild(style); } // linear feedback shift register to find a random approximation - function nextRandom (v) { - return Math.abs((v >> 1) | (((v << 62) ^ (v << 61)) & (~(~0 << 63) << 62))) + function nextRandom(v) { + return Math.abs((v >> 1) | (((v << 62) ^ (v << 61)) & (~(~0 << 63) << 62))); } const exemptionLists = {}; - function shouldExemptUrl (type, url) { + function shouldExemptUrl(type, url) { for (const regex of exemptionLists[type]) { if (regex.test(url)) { - return true + return true; } } - return false + return false; } let debug = false; - function initStringExemptionLists (args) { + function initStringExemptionLists(args) { const { stringExemptionLists } = args; debug = args.debug; for (const type in stringExemptionLists) { @@ -92,18 +93,18 @@ * Best guess effort if the document is being framed * @returns {boolean} if we infer the document is framed */ - function isBeingFramed () { + function isBeingFramed() { if (globalThis.location && 'ancestorOrigins' in globalThis.location) { - return globalThis.location.ancestorOrigins.length > 0 + return globalThis.location.ancestorOrigins.length > 0; } - return globalThis.top !== globalThis.window + return globalThis.top !== globalThis.window; } /** * Best guess effort of the tabs hostname; where possible always prefer the args.site.domain * @returns {string|null} inferred tab hostname */ - function getTabHostname () { + function getTabHostname() { let framingOrigin = null; try { // @ts-expect-error - globalThis.top is possibly 'null' here @@ -124,7 +125,7 @@ } catch { framingOrigin = null; } - return framingOrigin + return framingOrigin; } /** @@ -133,12 +134,12 @@ * @param {string} exceptionDomain * @returns {boolean} */ - function matchHostname (hostname, exceptionDomain) { - return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`) + function matchHostname(hostname, exceptionDomain) { + return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`); } const lineTest = /(\()?(https?:[^)]+):[0-9]+:[0-9]+(\))?/; - function getStackTraceUrls (stack) { + function getStackTraceUrls(stack) { const urls = new Set$1(); try { const errorLines = stack.split('\n'); @@ -152,36 +153,36 @@ } catch (e) { // Fall through } - return urls + return urls; } - function getStackTraceOrigins (stack) { + function getStackTraceOrigins(stack) { const urls = getStackTraceUrls(stack); const origins = new Set$1(); for (const url of urls) { origins.add(url.hostname); } - return origins + return origins; } // Checks the stack trace if there are known libraries that are broken. - function shouldExemptMethod (type) { + function shouldExemptMethod(type) { // Short circuit stack tracing if we don't have checks if (!(type in exemptionLists) || exemptionLists[type].length === 0) { - return false + return false; } const stack = getStack(); const errorFiles = getStackTraceUrls(stack); for (const path of errorFiles) { if (shouldExemptUrl(type, path.href)) { - return true + return true; } } - return false + return false; } // Iterate through the key, passing an item index and a byte to be modified - function iterateDataKey (key, callback) { + function iterateDataKey(key, callback) { let item = key.charCodeAt(0); for (const i in key) { let byte = key.charCodeAt(i); @@ -189,7 +190,7 @@ const res = callback(item, byte); // Exit early if callback returns null if (res === null) { - return + return; } // find next item to perturb @@ -201,27 +202,27 @@ } } - function isFeatureBroken (args, feature) { + function isFeatureBroken(args, feature) { return isWindowsSpecificFeature(feature) ? !args.site.enabledFeatures.includes(feature) - : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature) + : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature); } - function camelcase (dashCaseText) { + function camelcase(dashCaseText) { return dashCaseText.replace(/-(.)/g, (match, letter) => { - return letter.toUpperCase() - }) + return letter.toUpperCase(); + }); } // We use this method to detect M1 macs and set appropriate API values to prevent sites from detecting fingerprinting protections - function isAppleSilicon () { + function isAppleSilicon() { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); // Best guess if the device is an Apple Silicon // https://stackoverflow.com/a/65412357 // @ts-expect-error - Object is possibly 'null' - return gl.getSupportedExtensions().indexOf('WEBGL_compressed_texture_etc') !== -1 + return gl.getSupportedExtensions().indexOf('WEBGL_compressed_texture_etc') !== -1; } /** @@ -229,23 +230,23 @@ * If a value contains a criteria that is a match for this environment then return that value. * Otherwise return the first value that doesn't have a criteria. * - * @param {*[]} configSetting - Config setting which should contain a list of possible values + * @param {ConfigSetting[]} configSetting - Config setting which should contain a list of possible values * @returns {*|undefined} - The value from the list that best matches the criteria in the config */ - function processAttrByCriteria (configSetting) { + function processAttrByCriteria(configSetting) { let bestOption; for (const item of configSetting) { if (item.criteria) { if (item.criteria.arch === 'AppleSilicon' && isAppleSilicon()) { bestOption = item; - break + break; } } else { bestOption = item; } } - return bestOption + return bestOption; } const functionMap = { @@ -253,76 +254,86 @@ debug: (...args) => { console.log('debugger', ...args); // eslint-disable-next-line no-debugger - debugger + debugger; }, - - noop: () => { } + + noop: () => {}, }; + /** + * @typedef {object} ConfigSetting + * @property {'undefined' | 'number' | 'string' | 'function' | 'boolean' | 'null' | 'array' | 'object'} type + * @property {string} [functionName] + * @property {boolean | string | number} value + * @property {object} [criteria] + * @property {string} criteria.arch + */ + /** * Processes a structured config setting and returns the value according to its type - * @param {*} configSetting + * @param {ConfigSetting} configSetting * @param {*} [defaultValue] * @returns */ - function processAttr (configSetting, defaultValue) { + function processAttr(configSetting, defaultValue) { if (configSetting === undefined) { - return defaultValue + return defaultValue; } const configSettingType = typeof configSetting; switch (configSettingType) { - case 'object': - if (Array.isArray(configSetting)) { - configSetting = processAttrByCriteria(configSetting); - if (configSetting === undefined) { - return defaultValue + case 'object': + if (Array.isArray(configSetting)) { + configSetting = processAttrByCriteria(configSetting); + if (configSetting === undefined) { + return defaultValue; + } } - } - if (!configSetting.type) { - return defaultValue - } + if (!configSetting.type) { + return defaultValue; + } - if (configSetting.type === 'function') { - if (configSetting.functionName && functionMap[configSetting.functionName]) { - return functionMap[configSetting.functionName] + if (configSetting.type === 'function') { + if (configSetting.functionName && functionMap[configSetting.functionName]) { + return functionMap[configSetting.functionName]; + } } - } - if (configSetting.type === 'undefined') { - return undefined - } + if (configSetting.type === 'undefined') { + return undefined; + } - return configSetting.value - default: - return defaultValue + // All JSON expressable types are handled here + return configSetting.value; + default: + return defaultValue; } } - function getStack () { - return new Error$1().stack + function getStack() { + return new Error$1().stack; } /** * @param {*[]} argsArray * @returns {string} */ - function debugSerialize (argsArray) { + function debugSerialize(argsArray) { const maxSerializedSize = 1000; const serializedArgs = argsArray.map((arg) => { try { const serializableOut = JSON.stringify(arg); if (serializableOut.length > maxSerializedSize) { - return `` + return ``; } - return serializableOut + return serializableOut; } catch (e) { // Sometimes this happens when we can't serialize an object to string but we still wish to log it and make other args readable - return '' + return ''; } }); - return JSON.stringify(serializedArgs) + return JSON.stringify(serializedArgs); } /** @@ -341,7 +352,7 @@ * @param {string} property * @param {ProxyObject

} proxyObject */ - constructor (feature, objectScope, property, proxyObject) { + constructor(feature, objectScope, property, proxyObject) { this.objectScope = objectScope; this.property = property; this.feature = feature; @@ -358,14 +369,14 @@ kind: this.property, documentUrl: document.location.href, stack: getStack(), - args: debugSerialize(args[2]) + args: debugSerialize(args[2]), }); } // The normal return value if (isExempt) { - return DDGReflect.apply(...args) + return DDGReflect.apply(...args); } - return proxyObject.apply(...args) + return proxyObject.apply(...args); }; const getMethod = (target, prop, receiver) => { this.feature.addDebugFlag(); @@ -373,11 +384,11 @@ const method = Reflect.get(target, prop, receiver).bind(target); Object.defineProperty(method, 'toString', { value: String.toString.bind(String.toString), - enumerable: false + enumerable: false, }); - return method + return method; } - return DDGReflect.get(target, prop, receiver) + return DDGReflect.get(target, prop, receiver); }; { this._native = objectScope[property]; @@ -389,41 +400,41 @@ } // Actually apply the proxy to the native property - overload () { + overload() { { this.objectScope[this.property] = this.internal; } } - overloadDescriptor () { + overloadDescriptor() { // TODO: this is not always correct! Use wrap* or shim* methods instead this.feature.defineProperty(this.objectScope, this.property, { value: this.internal, writable: true, enumerable: true, - configurable: true + configurable: true, }); } } const maxCounter = new Map(); - function numberOfTimesDebugged (feature) { + function numberOfTimesDebugged(feature) { if (!maxCounter.has(feature)) { maxCounter.set(feature, 1); } else { maxCounter.set(feature, maxCounter.get(feature) + 1); } - return maxCounter.get(feature) + return maxCounter.get(feature); } const DEBUG_MAX_TIMES = 5000; - function postDebugMessage (feature, message, allowNonDebug = false) { + function postDebugMessage(feature, message, allowNonDebug = false) { if (!debug && !allowNonDebug) { - return + return; } if (numberOfTimesDebugged(feature) > DEBUG_MAX_TIMES) { - return + return; } if (message.stack) { const scriptOrigins = [...getStackTraceOrigins(message.stack)]; @@ -431,7 +442,7 @@ } globalObj.postMessage({ action: feature, - message + message, }); } @@ -449,10 +460,10 @@ * @param {object[]} featureList * @returns {boolean} */ - function isUnprotectedDomain (topLevelHostname, featureList) { + function isUnprotectedDomain(topLevelHostname, featureList) { let unprotectedDomain = false; if (!topLevelHostname) { - return false + return false; } const domainParts = topLevelHostname.split('.'); @@ -460,12 +471,12 @@ while (domainParts.length > 1 && !unprotectedDomain) { const partialDomain = domainParts.join('.'); - unprotectedDomain = featureList.filter(domain => domain.domain === partialDomain).length > 0; + unprotectedDomain = featureList.filter((domain) => domain.domain === partialDomain).length > 0; domainParts.shift(); } - return unprotectedDomain + return unprotectedDomain; } /** @@ -487,11 +498,11 @@ /** * Used to inialize extension code in the load phase */ - function computeLimitedSiteObject () { + function computeLimitedSiteObject() { const topLevelHostname = getTabHostname(); return { - domain: topLevelHostname - } + domain: topLevelHostname, + }; } /** @@ -499,18 +510,18 @@ * @param {UserPreferences} preferences * @returns {string | number | undefined} */ - function getPlatformVersion (preferences) { + function getPlatformVersion(preferences) { if (preferences.versionNumber) { - return preferences.versionNumber + return preferences.versionNumber; } if (preferences.versionString) { - return preferences.versionString + return preferences.versionString; } - return undefined + return undefined; } - function parseVersionString (versionString) { - return versionString.split('.').map(Number) + function parseVersionString(versionString) { + return versionString.split('.').map(Number); } /** @@ -518,7 +529,7 @@ * @param {string} applicationVersionString * @returns {boolean} */ - function satisfiesMinVersion (minVersionString, applicationVersionString) { + function satisfiesMinVersion(minVersionString, applicationVersionString) { const minVersions = parseVersionString(minVersionString); const currentVersions = parseVersionString(applicationVersionString); const maxLength = Math.max(minVersions.length, currentVersions.length); @@ -526,13 +537,13 @@ const minNumberPart = minVersions[i] || 0; const currentVersionPart = currentVersions[i] || 0; if (currentVersionPart > minNumberPart) { - return true + return true; } if (currentVersionPart < minNumberPart) { - return false + return false; } } - return true + return true; } /** @@ -540,17 +551,17 @@ * @param {string | number | undefined} currentVersion * @returns {boolean} */ - function isSupportedVersion (minSupportedVersion, currentVersion) { + function isSupportedVersion(minSupportedVersion, currentVersion) { if (typeof currentVersion === 'string' && typeof minSupportedVersion === 'string') { if (satisfiesMinVersion(minSupportedVersion, currentVersion)) { - return true + return true; } } else if (typeof currentVersion === 'number' && typeof minSupportedVersion === 'number') { if (minSupportedVersion <= currentVersion) { - return true + return true; } } - return false + return false; } /** @@ -565,10 +576,10 @@ * @param {UserPreferences} preferences * @param {string[]} platformSpecificFeatures */ - function processConfig (data, userList, preferences, platformSpecificFeatures = []) { + function processConfig(data, userList, preferences, platformSpecificFeatures = []) { const topLevelHostname = getTabHostname(); const site = computeLimitedSiteObject(); - const allowlisted = userList.filter(domain => domain === topLevelHostname).length > 0; + const allowlisted = userList.filter((domain) => domain === topLevelHostname).length > 0; /** @type {Record} */ const output = { ...preferences }; if (output.platform) { @@ -582,7 +593,7 @@ output.site = Object.assign(site, { isBroken, allowlisted, - enabledFeatures + enabledFeatures, }); // Copy feature settings from remote config to preferences object @@ -590,7 +601,7 @@ output.trackerLookup = {"org":{"cdn77":{"rsc":{"1558334541":1}},"adsrvr":1,"ampproject":1,"browser-update":1,"flowplayer":1,"privacy-center":1,"webvisor":1,"framasoft":1,"do-not-tracker":1,"trackersimulator":1},"io":{"1dmp":1,"1rx":1,"4dex":1,"adnami":1,"aidata":1,"arcspire":1,"bidr":1,"branch":1,"center":1,"cloudimg":1,"concert":1,"connectad":1,"cordial":1,"dcmn":1,"extole":1,"getblue":1,"hbrd":1,"instana":1,"karte":1,"leadsmonitor":1,"litix":1,"lytics":1,"marchex":1,"mediago":1,"mrf":1,"narrative":1,"ntv":1,"optad360":1,"oracleinfinity":1,"oribi":1,"p-n":1,"personalizer":1,"pghub":1,"piano":1,"powr":1,"pzz":1,"searchspring":1,"segment":1,"siteimproveanalytics":1,"sspinc":1,"t13":1,"webgains":1,"wovn":1,"yellowblue":1,"zprk":1,"axept":1,"akstat":1,"clarium":1,"hotjar":1},"com":{"2020mustang":1,"33across":1,"360yield":1,"3lift":1,"4dsply":1,"4strokemedia":1,"8353e36c2a":1,"a-mx":1,"a2z":1,"aamsitecertifier":1,"absorbingband":1,"abstractedauthority":1,"abtasty":1,"acexedge":1,"acidpigs":1,"acsbapp":1,"acuityplatform":1,"ad-score":1,"ad-stir":1,"adalyser":1,"adapf":1,"adara":1,"adblade":1,"addthis":1,"addtoany":1,"adelixir":1,"adentifi":1,"adextrem":1,"adgrx":1,"adhese":1,"adition":1,"adkernel":1,"adlightning":1,"adlooxtracking":1,"admanmedia":1,"admedo":1,"adnium":1,"adnxs-simple":1,"adnxs":1,"adobedtm":1,"adotmob":1,"adpone":1,"adpushup":1,"adroll":1,"adrta":1,"ads-twitter":1,"ads3-adnow":1,"adsafeprotected":1,"adstanding":1,"adswizz":1,"adtdp":1,"adtechus":1,"adtelligent":1,"adthrive":1,"adtlgc":1,"adtng":1,"adultfriendfinder":1,"advangelists":1,"adventive":1,"adventori":1,"advertising":1,"aegpresents":1,"affinity":1,"affirm":1,"agilone":1,"agkn":1,"aimbase":1,"albacross":1,"alcmpn":1,"alexametrics":1,"alicdn":1,"alikeaddition":1,"aliveachiever":1,"aliyuncs":1,"alluringbucket":1,"aloofvest":1,"amazon-adsystem":1,"amazon":1,"ambiguousafternoon":1,"amplitude":1,"analytics-egain":1,"aniview":1,"annoyedairport":1,"annoyingclover":1,"anyclip":1,"anymind360":1,"app-us1":1,"appboycdn":1,"appdynamics":1,"appsflyer":1,"aralego":1,"aspiringattempt":1,"aswpsdkus":1,"atemda":1,"att":1,"attentivemobile":1,"attractionbanana":1,"audioeye":1,"audrte":1,"automaticside":1,"avanser":1,"avmws":1,"aweber":1,"aweprt":1,"azure":1,"b0e8":1,"badgevolcano":1,"bagbeam":1,"ballsbanana":1,"bandborder":1,"batch":1,"bawdybalance":1,"bc0a":1,"bdstatic":1,"bedsberry":1,"beginnerpancake":1,"benchmarkemail":1,"betweendigital":1,"bfmio":1,"bidtheatre":1,"billowybelief":1,"bimbolive":1,"bing":1,"bizographics":1,"bizrate":1,"bkrtx":1,"blismedia":1,"blogherads":1,"bluecava":1,"bluekai":1,"blushingbread":1,"boatwizard":1,"boilingcredit":1,"boldchat":1,"booking":1,"borderfree":1,"bounceexchange":1,"brainlyads":1,"brand-display":1,"brandmetrics":1,"brealtime":1,"brightfunnel":1,"brightspotcdn":1,"btloader":1,"btstatic":1,"bttrack":1,"btttag":1,"bumlam":1,"butterbulb":1,"buttonladybug":1,"buzzfeed":1,"buzzoola":1,"byside":1,"c3tag":1,"cabnnr":1,"calculatorstatement":1,"callrail":1,"calltracks":1,"capablecup":1,"captcha-delivery":1,"carpentercomparison":1,"cartstack":1,"carvecakes":1,"casalemedia":1,"cattlecommittee":1,"cdninstagram":1,"cdnwidget":1,"channeladvisor":1,"chargecracker":1,"chartbeat":1,"chatango":1,"chaturbate":1,"cheqzone":1,"cherriescare":1,"chickensstation":1,"childlikecrowd":1,"childlikeform":1,"chocolateplatform":1,"cintnetworks":1,"circlelevel":1,"ck-ie":1,"clcktrax":1,"cleanhaircut":1,"clearbit":1,"clearbitjs":1,"clickagy":1,"clickcease":1,"clickcertain":1,"clicktripz":1,"clientgear":1,"cloudflare":1,"cloudflareinsights":1,"cloudflarestream":1,"cobaltgroup":1,"cobrowser":1,"cognitivlabs":1,"colossusssp":1,"combativecar":1,"comm100":1,"googleapis":{"commondatastorage":1,"imasdk":1,"storage":1,"fonts":1,"maps":1,"www":1},"company-target":1,"condenastdigital":1,"confusedcart":1,"connatix":1,"contextweb":1,"conversionruler":1,"convertkit":1,"convertlanguage":1,"cootlogix":1,"coveo":1,"cpmstar":1,"cquotient":1,"crabbychin":1,"cratecamera":1,"crazyegg":1,"creative-serving":1,"creativecdn":1,"criteo":1,"crowdedmass":1,"crowdriff":1,"crownpeak":1,"crsspxl":1,"ctnsnet":1,"cudasvc":1,"cuddlethehyena":1,"cumbersomecarpenter":1,"curalate":1,"curvedhoney":1,"cushiondrum":1,"cutechin":1,"cxense":1,"d28dc30335":1,"dailymotion":1,"damdoor":1,"dampdock":1,"dapperfloor":1,"datadoghq-browser-agent":1,"decisivebase":1,"deepintent":1,"defybrick":1,"delivra":1,"demandbase":1,"detectdiscovery":1,"devilishdinner":1,"dimelochat":1,"disagreeabledrop":1,"discreetfield":1,"disqus":1,"dmpxs":1,"dockdigestion":1,"dotomi":1,"doubleverify":1,"drainpaste":1,"dramaticdirection":1,"driftt":1,"dtscdn":1,"dtscout":1,"dwin1":1,"dynamics":1,"dynamicyield":1,"dynatrace":1,"ebaystatic":1,"ecal":1,"eccmp":1,"elfsight":1,"elitrack":1,"eloqua":1,"en25":1,"encouragingthread":1,"enormousearth":1,"ensighten":1,"enviousshape":1,"eqads":1,"ero-advertising":1,"esputnik":1,"evergage":1,"evgnet":1,"exdynsrv":1,"exelator":1,"exoclick":1,"exosrv":1,"expansioneggnog":1,"expedia":1,"expertrec":1,"exponea":1,"exponential":1,"extole":1,"ezodn":1,"ezoic":1,"ezoiccdn":1,"facebook":1,"facil-iti":1,"fadewaves":1,"fallaciousfifth":1,"farmergoldfish":1,"fastly-insights":1,"fearlessfaucet":1,"fiftyt":1,"financefear":1,"fitanalytics":1,"five9":1,"fixedfold":1,"fksnk":1,"flashtalking":1,"flipp":1,"flowerstreatment":1,"floweryflavor":1,"flutteringfireman":1,"flux-cdn":1,"foresee":1,"fortunatemark":1,"fouanalytics":1,"fox":1,"fqtag":1,"frailfruit":1,"freezingbuilding":1,"fronttoad":1,"fullstory":1,"functionalfeather":1,"fuzzybasketball":1,"gammamaximum":1,"gbqofs":1,"geetest":1,"geistm":1,"geniusmonkey":1,"geoip-js":1,"getbread":1,"getcandid":1,"getclicky":1,"getdrip":1,"getelevar":1,"getrockerbox":1,"getshogun":1,"getsitecontrol":1,"giraffepiano":1,"glassdoor":1,"gloriousbeef":1,"godpvqnszo":1,"google-analytics":1,"google":1,"googleadservices":1,"googlehosted":1,"googleoptimize":1,"googlesyndication":1,"googletagmanager":1,"googletagservices":1,"gorgeousedge":1,"govx":1,"grainmass":1,"greasysquare":1,"greylabeldelivery":1,"groovehq":1,"growsumo":1,"gstatic":1,"guarantee-cdn":1,"guiltlessbasketball":1,"gumgum":1,"haltingbadge":1,"hammerhearing":1,"handsomelyhealth":1,"harborcaption":1,"hawksearch":1,"amazonaws":{"us-east-2":{"s3":{"hb-obv2":1}}},"heapanalytics":1,"hellobar":1,"hhbypdoecp":1,"hiconversion":1,"highwebmedia":1,"histats":1,"hlserve":1,"hocgeese":1,"hollowafterthought":1,"honorableland":1,"hotjar":1,"hp":1,"hs-banner":1,"htlbid":1,"htplayground":1,"hubspot":1,"ib-ibi":1,"id5-sync":1,"igodigital":1,"iheart":1,"iljmp":1,"illiweb":1,"impactcdn":1,"impactradius-event":1,"impressionmonster":1,"improvedcontactform":1,"improvedigital":1,"imrworldwide":1,"indexww":1,"infolinks":1,"infusionsoft":1,"inmobi":1,"inq":1,"inside-graph":1,"instagram":1,"intentiq":1,"intergient":1,"investingchannel":1,"invocacdn":1,"iperceptions":1,"iplsc":1,"ipredictive":1,"iteratehq":1,"ivitrack":1,"j93557g":1,"jaavnacsdw":1,"jimstatic":1,"journity":1,"js7k":1,"jscache":1,"juiceadv":1,"juicyads":1,"justanswer":1,"justpremium":1,"jwpcdn":1,"kakao":1,"kampyle":1,"kargo":1,"kissmetrics":1,"klarnaservices":1,"klaviyo":1,"knottyswing":1,"krushmedia":1,"ktkjmp":1,"kxcdn":1,"laboredlocket":1,"ladesk":1,"ladsp":1,"laughablelizards":1,"leadsrx":1,"lendingtree":1,"levexis":1,"liadm":1,"licdn":1,"lightboxcdn":1,"lijit":1,"linkedin":1,"linksynergy":1,"list-manage":1,"listrakbi":1,"livechatinc":1,"livejasmin":1,"localytics":1,"loggly":1,"loop11":1,"looseloaf":1,"lovelydrum":1,"lunchroomlock":1,"lwonclbench":1,"macromill":1,"maddeningpowder":1,"mailchimp":1,"mailchimpapp":1,"mailerlite":1,"maillist-manage":1,"marinsm":1,"marketiq":1,"marketo":1,"marphezis":1,"marriedbelief":1,"materialparcel":1,"matheranalytics":1,"mathtag":1,"maxmind":1,"mczbf":1,"measlymiddle":1,"medallia":1,"meddleplant":1,"media6degrees":1,"mediacategory":1,"mediavine":1,"mediawallahscript":1,"medtargetsystem":1,"megpxs":1,"memberful":1,"memorizematch":1,"mentorsticks":1,"metaffiliation":1,"metricode":1,"metricswpsh":1,"mfadsrvr":1,"mgid":1,"micpn":1,"microadinc":1,"minutemedia-prebid":1,"minutemediaservices":1,"mixpo":1,"mkt932":1,"mktoresp":1,"mktoweb":1,"ml314":1,"moatads":1,"mobtrakk":1,"monsido":1,"mookie1":1,"motionflowers":1,"mountain":1,"mouseflow":1,"mpeasylink":1,"mql5":1,"mrtnsvr":1,"murdoog":1,"mxpnl":1,"mybestpro":1,"myregistry":1,"nappyattack":1,"navistechnologies":1,"neodatagroup":1,"nervoussummer":1,"netmng":1,"newrelic":1,"newscgp":1,"nextdoor":1,"ninthdecimal":1,"nitropay":1,"noibu":1,"nondescriptnote":1,"nosto":1,"npttech":1,"ntvpwpush":1,"nuance":1,"nutritiousbean":1,"nxsttv":1,"omappapi":1,"omnisnippet1":1,"omnisrc":1,"omnitagjs":1,"ondemand":1,"oneall":1,"onesignal":1,"onetag-sys":1,"oo-syringe":1,"ooyala":1,"opecloud":1,"opentext":1,"opera":1,"opmnstr":1,"opti-digital":1,"optimicdn":1,"optimizely":1,"optinmonster":1,"optmnstr":1,"optmstr":1,"optnmnstr":1,"optnmstr":1,"osano":1,"otm-r":1,"outbrain":1,"overconfidentfood":1,"ownlocal":1,"pailpatch":1,"panickypancake":1,"panoramicplane":1,"parastorage":1,"pardot":1,"parsely":1,"partplanes":1,"patreon":1,"paypal":1,"pbstck":1,"pcmag":1,"peerius":1,"perfdrive":1,"perfectmarket":1,"permutive":1,"picreel":1,"pinterest":1,"pippio":1,"piwikpro":1,"pixlee":1,"placidperson":1,"pleasantpump":1,"plotrabbit":1,"pluckypocket":1,"pocketfaucet":1,"possibleboats":1,"postaffiliatepro":1,"postrelease":1,"potatoinvention":1,"powerfulcopper":1,"predictplate":1,"prepareplanes":1,"pricespider":1,"priceypies":1,"pricklydebt":1,"profusesupport":1,"proofpoint":1,"protoawe":1,"providesupport":1,"pswec":1,"psychedelicarithmetic":1,"psyma":1,"ptengine":1,"publir":1,"pubmatic":1,"pubmine":1,"pubnation":1,"qualaroo":1,"qualtrics":1,"quantcast":1,"quantserve":1,"quantummetric":1,"quietknowledge":1,"quizzicalpartner":1,"quizzicalzephyr":1,"quora":1,"r42tag":1,"radiateprose":1,"railwayreason":1,"rakuten":1,"rambunctiousflock":1,"rangeplayground":1,"rating-widget":1,"realsrv":1,"rebelswing":1,"reconditerake":1,"reconditerespect":1,"recruitics":1,"reddit":1,"redditstatic":1,"rehabilitatereason":1,"repeatsweater":1,"reson8":1,"resonantrock":1,"resonate":1,"responsiveads":1,"restrainstorm":1,"restructureinvention":1,"retargetly":1,"revcontent":1,"rezync":1,"rfihub":1,"rhetoricalloss":1,"richaudience":1,"righteouscrayon":1,"rightfulfall":1,"riotgames":1,"riskified":1,"rkdms":1,"rlcdn":1,"rmtag":1,"rogersmedia":1,"rokt":1,"route":1,"rtbsystem":1,"rubiconproject":1,"ruralrobin":1,"s-onetag":1,"saambaa":1,"sablesong":1,"sail-horizon":1,"salesforceliveagent":1,"samestretch":1,"sascdn":1,"satisfycork":1,"savoryorange":1,"scarabresearch":1,"scaredsnakes":1,"scaredsong":1,"scaredstomach":1,"scarfsmash":1,"scene7":1,"scholarlyiq":1,"scintillatingsilver":1,"scorecardresearch":1,"screechingstove":1,"screenpopper":1,"scribblestring":1,"sddan":1,"seatsmoke":1,"securedvisit":1,"seedtag":1,"sefsdvc":1,"segment":1,"sekindo":1,"selectivesummer":1,"selfishsnake":1,"servebom":1,"servedbyadbutler":1,"servenobid":1,"serverbid":1,"serving-sys":1,"shakegoldfish":1,"shamerain":1,"shapecomb":1,"shappify":1,"shareaholic":1,"sharethis":1,"sharethrough":1,"shopifyapps":1,"shopperapproved":1,"shrillspoon":1,"sibautomation":1,"sicksmash":1,"signifyd":1,"singroot":1,"site":1,"siteimprove":1,"siteimproveanalytics":1,"sitescout":1,"sixauthority":1,"skillfuldrop":1,"skimresources":1,"skisofa":1,"sli-spark":1,"slickstream":1,"slopesoap":1,"smadex":1,"smartadserver":1,"smashquartz":1,"smashsurprise":1,"smg":1,"smilewanted":1,"smoggysnakes":1,"snapchat":1,"snapkit":1,"snigelweb":1,"socdm":1,"sojern":1,"songsterritory":1,"sonobi":1,"soundstocking":1,"spectacularstamp":1,"speedcurve":1,"sphereup":1,"spiceworks":1,"spookyexchange":1,"spookyskate":1,"spookysleet":1,"sportradarserving":1,"sportslocalmedia":1,"spotxchange":1,"springserve":1,"srvmath":1,"ssl-images-amazon":1,"stackadapt":1,"stakingsmile":1,"statcounter":1,"steadfastseat":1,"steadfastsound":1,"steadfastsystem":1,"steelhousemedia":1,"steepsquirrel":1,"stereotypedsugar":1,"stickyadstv":1,"stiffgame":1,"stingycrush":1,"straightnest":1,"stripchat":1,"strivesquirrel":1,"strokesystem":1,"stupendoussleet":1,"stupendoussnow":1,"stupidscene":1,"sulkycook":1,"sumo":1,"sumologic":1,"sundaysky":1,"superficialeyes":1,"superficialsquare":1,"surveymonkey":1,"survicate":1,"svonm":1,"swankysquare":1,"symantec":1,"taboola":1,"tailtarget":1,"talkable":1,"tamgrt":1,"tangycover":1,"taobao":1,"tapad":1,"tapioni":1,"taptapnetworks":1,"taskanalytics":1,"tealiumiq":1,"techlab-cdn":1,"technoratimedia":1,"techtarget":1,"tediousticket":1,"teenytinyshirt":1,"tendertest":1,"the-ozone-project":1,"theadex":1,"themoneytizer":1,"theplatform":1,"thestar":1,"thinkitten":1,"threetruck":1,"thrtle":1,"tidaltv":1,"tidiochat":1,"tiktok":1,"tinypass":1,"tiqcdn":1,"tiresomethunder":1,"trackjs":1,"traffichaus":1,"trafficjunky":1,"trafmag":1,"travelaudience":1,"treasuredata":1,"tremorhub":1,"trendemon":1,"tribalfusion":1,"trovit":1,"trueleadid":1,"truoptik":1,"truste":1,"trustpilot":1,"trvdp":1,"tsyndicate":1,"tubemogul":1,"turn":1,"tvpixel":1,"tvsquared":1,"tweakwise":1,"twitter":1,"tynt":1,"typicalteeth":1,"u5e":1,"ubembed":1,"uidapi":1,"ultraoranges":1,"unbecominglamp":1,"unbxdapi":1,"undertone":1,"uninterestedquarter":1,"unpkg":1,"unrulymedia":1,"unwieldyhealth":1,"unwieldyplastic":1,"upsellit":1,"urbanairship":1,"usabilla":1,"usbrowserspeed":1,"usemessages":1,"userreport":1,"uservoice":1,"valuecommerce":1,"vengefulgrass":1,"vidazoo":1,"videoplayerhub":1,"vidoomy":1,"viglink":1,"visualwebsiteoptimizer":1,"vivaclix":1,"vk":1,"vlitag":1,"voicefive":1,"volatilevessel":1,"voraciousgrip":1,"voxmedia":1,"vrtcal":1,"w3counter":1,"walkme":1,"warmafterthought":1,"warmquiver":1,"webcontentassessor":1,"webengage":1,"webeyez":1,"webtraxs":1,"webtrends-optimize":1,"webtrends":1,"wgplayer":1,"woosmap":1,"worldoftulo":1,"wpadmngr":1,"wpshsdk":1,"wpushsdk":1,"wsod":1,"wt-safetag":1,"wysistat":1,"xg4ken":1,"xiti":1,"xlirdr":1,"xlivrdr":1,"xnxx-cdn":1,"y-track":1,"yahoo":1,"yandex":1,"yieldmo":1,"yieldoptimizer":1,"yimg":1,"yotpo":1,"yottaa":1,"youtube-nocookie":1,"youtube":1,"zemanta":1,"zendesk":1,"zeotap":1,"zestycrime":1,"zonos":1,"zoominfo":1,"zopim":1,"createsend1":1,"veoxa":1,"parchedsofa":1,"sooqr":1,"adtraction":1,"addthisedge":1,"adsymptotic":1,"bootstrapcdn":1,"bugsnag":1,"dmxleo":1,"dtssrv":1,"fontawesome":1,"hs-scripts":1,"jwpltx":1,"nereserv":1,"onaudience":1,"outbrainimg":1,"quantcount":1,"rtactivate":1,"shopifysvc":1,"stripe":1,"twimg":1,"vimeo":1,"vimeocdn":1,"wp":1,"2znp09oa":1,"4jnzhl0d0":1,"6ldu6qa":1,"82o9v830":1,"abilityscale":1,"aboardamusement":1,"aboardlevel":1,"abovechat":1,"abruptroad":1,"absentairport":1,"absorbingcorn":1,"absorbingprison":1,"abstractedamount":1,"absurdapple":1,"abundantcoin":1,"acceptableauthority":1,"accurateanimal":1,"accuratecoal":1,"achieverknee":1,"acidicstraw":1,"acridangle":1,"acridtwist":1,"actoramusement":1,"actuallysheep":1,"actuallysnake":1,"actuallything":1,"adamantsnail":1,"addictedattention":1,"adorableanger":1,"adorableattention":1,"adventurousamount":1,"afraidlanguage":1,"aftermathbrother":1,"agilebreeze":1,"agreeablearch":1,"agreeabletouch":1,"aheadday":1,"aheadgrow":1,"aheadmachine":1,"ak0gsh40":1,"alertarithmetic":1,"aliasanvil":1,"alleythecat":1,"aloofmetal":1,"alpineactor":1,"ambientdusk":1,"ambientlagoon":1,"ambiguousanger":1,"ambiguousdinosaurs":1,"ambiguousincome":1,"ambrosialsummit":1,"amethystzenith":1,"amuckafternoon":1,"amusedbucket":1,"analogwonder":1,"analyzecorona":1,"ancientact":1,"annoyingacoustics":1,"anxiousapples":1,"aquaticowl":1,"ar1nvz5":1,"archswimming":1,"aromamirror":1,"arrivegrowth":1,"artthevoid":1,"aspiringapples":1,"aspiringtoy":1,"astonishingfood":1,"astralhustle":1,"astrallullaby":1,"attendchase":1,"attractivecap":1,"audioarctic":1,"automaticturkey":1,"availablerest":1,"avalonalbum":1,"averageactivity":1,"awarealley":1,"awesomeagreement":1,"awzbijw":1,"axiomaticalley":1,"axiomaticanger":1,"azuremystique":1,"backupcat":1,"badgeboat":1,"badgerabbit":1,"baitbaseball":1,"balloonbelieve":1,"bananabarrel":1,"barbarousbase":1,"basilfish":1,"basketballbelieve":1,"baskettexture":1,"bawdybeast":1,"beamvolcano":1,"beancontrol":1,"bearmoonlodge":1,"beetleend":1,"begintrain":1,"berserkhydrant":1,"bespokesandals":1,"bestboundary":1,"bewilderedbattle":1,"bewilderedblade":1,"bhcumsc":1,"bikepaws":1,"bikesboard":1,"billowybead":1,"binspiredtees":1,"birthdaybelief":1,"blackbrake":1,"bleachbubble":1,"bleachscarecrow":1,"bleedlight":1,"blesspizzas":1,"blissfulcrescendo":1,"blissfullagoon":1,"blueeyedblow":1,"blushingbeast":1,"boatsvest":1,"boilingbeetle":1,"boostbehavior":1,"boredcrown":1,"bouncyproperty":1,"boundarybusiness":1,"boundlessargument":1,"boundlessbrake":1,"boundlessveil":1,"brainybasin":1,"brainynut":1,"branchborder":1,"brandsfive":1,"brandybison":1,"bravebone":1,"bravecalculator":1,"breadbalance":1,"breakableinsurance":1,"breakfastboat":1,"breezygrove":1,"brianwould":1,"brighttoe":1,"briskstorm":1,"broadborder":1,"broadboundary":1,"broadcastbed":1,"broaddoor":1,"brotherslocket":1,"bruisebaseball":1,"brunchforher":1,"buildingknife":1,"bulbbait":1,"burgersalt":1,"burlywhistle":1,"burnbubble":1,"bushesbag":1,"bustlingbath":1,"bustlingbook":1,"butterburst":1,"cakesdrum":1,"calculatingcircle":1,"calculatingtoothbrush":1,"callousbrake":1,"calmcactus":1,"calypsocapsule":1,"cannonchange":1,"capablecows":1,"capriciouscorn":1,"captivatingcanyon":1,"captivatingillusion":1,"captivatingpanorama":1,"captivatingperformance":1,"carefuldolls":1,"caringcast":1,"caringzinc":1,"carloforward":1,"carscannon":1,"cartkitten":1,"catalogcake":1,"catschickens":1,"causecherry":1,"cautiouscamera":1,"cautiouscherries":1,"cautiouscrate":1,"cautiouscredit":1,"cavecurtain":1,"ceciliavenus":1,"celestialeuphony":1,"celestialquasar":1,"celestialspectra":1,"chaireggnog":1,"chairscrack":1,"chairsdonkey":1,"chalkoil":1,"changeablecats":1,"channelcamp":1,"charmingplate":1,"charscroll":1,"cheerycraze":1,"chessbranch":1,"chesscolor":1,"chesscrowd":1,"childlikeexample":1,"chilledliquid":1,"chingovernment":1,"chinsnakes":1,"chipperisle":1,"chivalrouscord":1,"chubbycreature":1,"chunkycactus":1,"cicdserver":1,"cinemabonus":1,"clammychicken":1,"cloisteredcord":1,"cloisteredcurve":1,"closedcows":1,"closefriction":1,"cloudhustles":1,"cloudjumbo":1,"clovercabbage":1,"clumsycar":1,"coatfood":1,"cobaltoverture":1,"coffeesidehustle":1,"coldbalance":1,"coldcreatives":1,"colorfulafterthought":1,"colossalclouds":1,"colossalcoat":1,"colossalcry":1,"combativedetail":1,"combbit":1,"combcattle":1,"combcompetition":1,"cometquote":1,"comfortablecheese":1,"comfygoodness":1,"companyparcel":1,"comparereaction":1,"compiledoctor":1,"concernedchange":1,"concernedchickens":1,"condemnedcomb":1,"conditionchange":1,"conditioncrush":1,"confesschairs":1,"configchain":1,"connectashelf":1,"consciouschairs":1,"consciouscheese":1,"consciousdirt":1,"consumerzero":1,"controlcola":1,"controlhall":1,"convertbatch":1,"cooingcoal":1,"coordinatedbedroom":1,"coordinatedcoat":1,"copycarpenter":1,"copyrightaccesscontrols":1,"coralreverie":1,"corgibeachday":1,"cosmicsculptor":1,"cosmosjackson":1,"courageousbaby":1,"coverapparatus":1,"coverlayer":1,"cozydusk":1,"cozyhillside":1,"cozytryst":1,"crackedsafe":1,"crafthenry":1,"crashchance":1,"craterbox":1,"creatorcherry":1,"creatorpassenger":1,"creaturecabbage":1,"crimsonmeadow":1,"critictruck":1,"crookedcreature":1,"cruisetourist":1,"cryptvalue":1,"crystalboulevard":1,"crystalstatus":1,"cubchannel":1,"cubepins":1,"cuddlycake":1,"cuddlylunchroom":1,"culturedcamera":1,"culturedfeather":1,"cumbersomecar":1,"cumbersomecloud":1,"curiouschalk":1,"curioussuccess":1,"curlycannon":1,"currentcollar":1,"curtaincows":1,"curvycord":1,"curvycry":1,"cushionpig":1,"cutcurrent":1,"cyclopsdial":1,"dailydivision":1,"damagedadvice":1,"damageddistance":1,"dancemistake":1,"dandydune":1,"dandyglow":1,"dapperdiscussion":1,"datastoried":1,"daughterstone":1,"daymodern":1,"dazzlingbook":1,"deafeningdock":1,"deafeningdowntown":1,"debonairdust":1,"debonairtree":1,"debugentity":1,"decidedrum":1,"decisivedrawer":1,"decisiveducks":1,"decoycreation":1,"deerbeginner":1,"defeatedbadge":1,"defensevest":1,"degreechariot":1,"delegatediscussion":1,"delicatecascade":1,"deliciousducks":1,"deltafault":1,"deluxecrate":1,"dependenttrip":1,"desirebucket":1,"desiredirt":1,"detailedgovernment":1,"detailedkitten":1,"detectdinner":1,"detourgame":1,"deviceseal":1,"deviceworkshop":1,"dewdroplagoon":1,"difficultfog":1,"digestiondrawer":1,"dinnerquartz":1,"diplomahawaii":1,"direfuldesk":1,"discreetquarter":1,"distributionneck":1,"distributionpocket":1,"distributiontomatoes":1,"disturbedquiet":1,"divehope":1,"dk4ywix":1,"dogsonclouds":1,"dollardelta":1,"doubledefend":1,"doubtdrawer":1,"dq95d35":1,"dreamycanyon":1,"driftpizza":1,"drollwharf":1,"drydrum":1,"dustydime":1,"dustyhammer":1,"eagereden":1,"eagerflame":1,"eagerknight":1,"earthyfarm":1,"eatablesquare":1,"echochief":1,"echoinghaven":1,"effervescentcoral":1,"effervescentvista":1,"effulgentnook":1,"effulgenttempest":1,"ejyymghi":1,"elasticchange":1,"elderlybean":1,"elderlytown":1,"elephantqueue":1,"elusivebreeze":1,"elusivecascade":1,"elysiantraverse":1,"embellishedmeadow":1,"embermosaic":1,"emberwhisper":1,"eminentbubble":1,"eminentend":1,"emptyescort":1,"enchantedskyline":1,"enchantingdiscovery":1,"enchantingenchantment":1,"enchantingmystique":1,"enchantingtundra":1,"enchantingvalley":1,"encourageshock":1,"endlesstrust":1,"endurablebulb":1,"energeticexample":1,"energeticladybug":1,"engineergrape":1,"engineertrick":1,"enigmaticblossom":1,"enigmaticcanyon":1,"enigmaticvoyage":1,"enormousfoot":1,"enterdrama":1,"entertainskin":1,"enthusiastictemper":1,"enviousthread":1,"equablekettle":1,"etherealbamboo":1,"ethereallagoon":1,"etherealpinnacle":1,"etherealquasar":1,"etherealripple":1,"evanescentedge":1,"evasivejar":1,"eventexistence":1,"exampleshake":1,"excitingtub":1,"exclusivebrass":1,"executeknowledge":1,"exhibitsneeze":1,"exquisiteartisanship":1,"extractobservation":1,"extralocker":1,"extramonies":1,"exuberantedge":1,"facilitatebreakfast":1,"fadechildren":1,"fadedsnow":1,"fairfeeling":1,"fairiesbranch":1,"fairytaleflame":1,"falseframe":1,"familiarrod":1,"fancyactivity":1,"fancydune":1,"fancygrove":1,"fangfeeling":1,"fantastictone":1,"farethief":1,"farshake":1,"farsnails":1,"fastenfather":1,"fasterfineart":1,"fasterjson":1,"fatcoil":1,"faucetfoot":1,"faultycanvas":1,"fearfulfish":1,"fearfulmint":1,"fearlesstramp":1,"featherstage":1,"feeblestamp":1,"feignedfaucet":1,"fernwaycloud":1,"fertilefeeling":1,"fewjuice":1,"fewkittens":1,"finalizeforce":1,"finestpiece":1,"finitecube":1,"firecatfilms":1,"fireworkcamp":1,"firstendpoint":1,"firstfrogs":1,"firsttexture":1,"fitmessage":1,"fivesidedsquare":1,"flakyfeast":1,"flameuncle":1,"flimsycircle":1,"flimsythought":1,"flippedfunnel":1,"floodprincipal":1,"flourishingcollaboration":1,"flourishingendeavor":1,"flourishinginnovation":1,"flourishingpartnership":1,"flowersornament":1,"flowerycreature":1,"floweryfact":1,"floweryoperation":1,"foambench":1,"followborder":1,"forecasttiger":1,"foretellfifth":1,"forevergears":1,"forgetfulflowers":1,"forgetfulsnail":1,"fractalcoast":1,"framebanana":1,"franticroof":1,"frantictrail":1,"frazzleart":1,"freakyglass":1,"frequentflesh":1,"friendlycrayon":1,"friendlyfold":1,"friendwool":1,"frightenedpotato":1,"frogator":1,"frogtray":1,"frugalfiestas":1,"fumblingform":1,"functionalcrown":1,"funoverbored":1,"funoverflow":1,"furnstudio":1,"furryfork":1,"furryhorses":1,"futuristicapparatus":1,"futuristicfairies":1,"futuristicfifth":1,"futuristicframe":1,"fuzzyaudio":1,"fuzzyerror":1,"gardenovens":1,"gaudyairplane":1,"geekactive":1,"generalprose":1,"generateoffice":1,"giantsvessel":1,"giddycoat":1,"gitcrumbs":1,"givevacation":1,"gladglen":1,"gladysway":1,"glamhawk":1,"gleamingcow":1,"gleaminghaven":1,"glisteningguide":1,"glisteningsign":1,"glitteringbrook":1,"glowingmeadow":1,"gluedpixel":1,"goldfishgrowth":1,"gondolagnome":1,"goodbark":1,"gracefulmilk":1,"grandfatherguitar":1,"gravitygive":1,"gravitykick":1,"grayoranges":1,"grayreceipt":1,"greyinstrument":1,"gripcorn":1,"groovyornament":1,"grouchybrothers":1,"grouchypush":1,"grumpydime":1,"grumpydrawer":1,"guardeddirection":1,"guardedschool":1,"guessdetail":1,"guidecent":1,"guildalpha":1,"gulliblegrip":1,"gustocooking":1,"gustygrandmother":1,"habitualhumor":1,"halcyoncanyon":1,"halcyonsculpture":1,"hallowedinvention":1,"haltingdivision":1,"haltinggold":1,"handleteeth":1,"handnorth":1,"handsomehose":1,"handsomeindustry":1,"handsomelythumb":1,"handsomeyam":1,"handyfield":1,"handyfireman":1,"handyincrease":1,"haplesshydrant":1,"haplessland":1,"happysponge":1,"harborcub":1,"harmonicbamboo":1,"harmonywing":1,"hatefulrequest":1,"headydegree":1,"headyhook":1,"healflowers":1,"hearinglizards":1,"heartbreakingmind":1,"hearthorn":1,"heavydetail":1,"heavyplayground":1,"helpcollar":1,"helpflame":1,"hfc195b":1,"highfalutinbox":1,"highfalutinhoney":1,"hilariouszinc":1,"historicalbeam":1,"homelycrown":1,"honeybulb":1,"honeywhipped":1,"honorablehydrant":1,"horsenectar":1,"hospitablehall":1,"hospitablehat":1,"howdyinbox":1,"humdrumhobbies":1,"humdrumtouch":1,"hurtgrape":1,"hypnoticwound":1,"hystericalcloth":1,"hystericalfinger":1,"idolscene":1,"idyllicjazz":1,"illinvention":1,"illustriousoatmeal":1,"immensehoney":1,"imminentshake":1,"importantmeat":1,"importedincrease":1,"importedinsect":1,"importlocate":1,"impossibleexpansion":1,"impossiblemove":1,"impulsejewel":1,"impulselumber":1,"incomehippo":1,"incompetentjoke":1,"inconclusiveaction":1,"infamousstream":1,"innocentlamp":1,"innocentwax":1,"inputicicle":1,"inquisitiveice":1,"inquisitiveinvention":1,"intelligentscissors":1,"intentlens":1,"interestdust":1,"internalcondition":1,"internalsink":1,"iotapool":1,"irritatingfog":1,"itemslice":1,"ivykiosk":1,"jadeitite":1,"jaderooster":1,"jailbulb":1,"joblessdrum":1,"jollylens":1,"joyfulkeen":1,"joyoussurprise":1,"jubilantaura":1,"jubilantcanyon":1,"jubilantcascade":1,"jubilantglimmer":1,"jubilanttempest":1,"jubilantwhisper":1,"justicejudo":1,"kaputquill":1,"keenquill":1,"kindhush":1,"kitesquirrel":1,"knitstamp":1,"laboredlight":1,"lameletters":1,"lamplow":1,"largebrass":1,"lasttaco":1,"leaplunchroom":1,"leftliquid":1,"lemonpackage":1,"lemonsandjoy":1,"liftedknowledge":1,"lightenafterthought":1,"lighttalon":1,"livelumber":1,"livelylaugh":1,"livelyreward":1,"livingsleet":1,"lizardslaugh":1,"loadsurprise":1,"lonelyflavor":1,"longingtrees":1,"lorenzourban":1,"losslace":1,"loudlunch":1,"loveseashore":1,"lp3tdqle":1,"ludicrousarch":1,"lumberamount":1,"luminousboulevard":1,"luminouscatalyst":1,"luminoussculptor":1,"lumpygnome":1,"lumpylumber":1,"lustroushaven":1,"lyricshook":1,"madebyintent":1,"magicaljoin":1,"magnetairport":1,"majesticmountainrange":1,"majesticwaterscape":1,"majesticwilderness":1,"maliciousmusic":1,"managedpush":1,"mantrafox":1,"marblediscussion":1,"markahouse":1,"markedmeasure":1,"marketspiders":1,"marriedmailbox":1,"marriedvalue":1,"massivemark":1,"materialisticmoon":1,"materialmilk":1,"materialplayground":1,"meadowlullaby":1,"meatydime":1,"mediatescarf":1,"mediumshort":1,"mellowhush":1,"mellowmailbox":1,"melodiouschorus":1,"melodiouscomposition":1,"meltmilk":1,"memopilot":1,"memorizeneck":1,"meremark":1,"merequartz":1,"merryopal":1,"merryvault":1,"messagenovice":1,"messyoranges":1,"mightyspiders":1,"mimosamajor":1,"mindfulgem":1,"minorcattle":1,"minusmental":1,"minuteburst":1,"miscreantmoon":1,"mistyhorizon":1,"mittencattle":1,"mixedreading":1,"modularmental":1,"monacobeatles":1,"moorshoes":1,"motionlessbag":1,"motionlessbelief":1,"motionlessmeeting":1,"movemeal":1,"muddledaftermath":1,"muddledmemory":1,"mundanenail":1,"mundanepollution":1,"mushywaste":1,"muteknife":1,"mutemailbox":1,"mysticalagoon":1,"naivestatement":1,"nappyneck":1,"neatshade":1,"nebulacrescent":1,"nebulajubilee":1,"nebulousamusement":1,"nebulousgarden":1,"nebulousquasar":1,"nebulousripple":1,"needlessnorth":1,"needyneedle":1,"neighborlywatch":1,"niftygraphs":1,"niftyhospital":1,"niftyjelly":1,"nightwound":1,"nimbleplot":1,"nocturnalloom":1,"nocturnalmystique":1,"noiselessplough":1,"nonchalantnerve":1,"nondescriptcrowd":1,"nondescriptstocking":1,"nostalgicknot":1,"nostalgicneed":1,"notifyglass":1,"nudgeduck":1,"nullnorth":1,"numberlessring":1,"numerousnest":1,"nuttyorganization":1,"oafishchance":1,"oafishobservation":1,"obscenesidewalk":1,"observantice":1,"oldfashionedoffer":1,"omgthink":1,"omniscientfeeling":1,"onlywoofs":1,"opalquill":1,"operationchicken":1,"operationnail":1,"oppositeoperation":1,"optimallimit":1,"opulentsylvan":1,"orientedargument":1,"orionember":1,"ourblogthing":1,"outgoinggiraffe":1,"outsidevibe":1,"outstandingincome":1,"outstandingsnails":1,"overkick":1,"overratedchalk":1,"oxygenfuse":1,"pailcrime":1,"painstakingpickle":1,"paintpear":1,"paleleaf":1,"pamelarandom":1,"panickycurtain":1,"parallelbulb":1,"pardonpopular":1,"parentpicture":1,"parsimoniouspolice":1,"passivepolo":1,"pastoralroad":1,"pawsnug":1,"peacefullimit":1,"pedromister":1,"pedropanther":1,"perceivequarter":1,"perkyjade":1,"petiteumbrella":1,"philippinch":1,"photographpan":1,"piespower":1,"piquantgrove":1,"piquantmeadow":1,"piquantpigs":1,"piquantprice":1,"piquantvortex":1,"pixeledhub":1,"pizzasnut":1,"placeframe":1,"placidactivity":1,"planebasin":1,"plantdigestion":1,"playfulriver":1,"plotparent":1,"pluckyzone":1,"poeticpackage":1,"pointdigestion":1,"pointlesshour":1,"pointlesspocket":1,"pointlessprofit":1,"pointlessrifle":1,"polarismagnet":1,"polishedcrescent":1,"polishedfolly":1,"politeplanes":1,"politicalflip":1,"politicalporter":1,"popplantation":1,"possiblepencil":1,"powderjourney":1,"powerfulblends":1,"preciousplanes":1,"prefixpatriot":1,"presetrabbits":1,"previousplayground":1,"previouspotato":1,"pricklypollution":1,"pristinegale":1,"probablepartner":1,"processplantation":1,"producepickle":1,"productsurfer":1,"profitrumour":1,"promiseair":1,"proofconvert":1,"propertypotato":1,"protestcopy":1,"psychedelicchess":1,"publicsofa":1,"puffyloss":1,"puffypaste":1,"puffypull":1,"puffypurpose":1,"pulsatingmeadow":1,"pumpedpancake":1,"pumpedpurpose":1,"punyplant":1,"puppytooth":1,"purposepipe":1,"quacksquirrel":1,"quaintcan":1,"quaintlake":1,"quantumlagoon":1,"quantumshine":1,"queenskart":1,"quillkick":1,"quirkybliss":1,"quirkysugar":1,"quixoticnebula":1,"rabbitbreath":1,"rabbitrifle":1,"radiantcanopy":1,"radiantlullaby":1,"railwaygiraffe":1,"raintwig":1,"rainyhand":1,"rainyrule":1,"rangecake":1,"raresummer":1,"reactjspdf":1,"readingguilt":1,"readymoon":1,"readysnails":1,"realizedoor":1,"realizerecess":1,"rebelclover":1,"rebelhen":1,"rebelsubway":1,"receiptcent":1,"receptiveink":1,"receptivereaction":1,"recessrain":1,"reconditeprison":1,"reflectivestatement":1,"refundradar":1,"regularplants":1,"regulatesleet":1,"relationrest":1,"reloadphoto":1,"rememberdiscussion":1,"rentinfinity":1,"replaceroute":1,"resonantbrush":1,"respectrain":1,"resplendentecho":1,"retrievemint":1,"rhetoricalactivity":1,"rhetoricalveil":1,"rhymezebra":1,"rhythmrule":1,"richstring":1,"rigidrobin":1,"rigidveil":1,"rigorlab":1,"ringplant":1,"ringsrecord":1,"ritzykey":1,"ritzyrepresentative":1,"ritzyveil":1,"rockpebbles":1,"rollconnection":1,"roofrelation":1,"roseincome":1,"rottenray":1,"rusticprice":1,"ruthlessdegree":1,"ruthlessmilk":1,"sableloss":1,"sablesmile":1,"sadloaf":1,"saffronrefuge":1,"sagargift":1,"saltsacademy":1,"samesticks":1,"samplesamba":1,"scarcecard":1,"scarceshock":1,"scarcesign":1,"scarcestructure":1,"scarcesurprise":1,"scaredcomfort":1,"scaredsidewalk":1,"scaredslip":1,"scaredsnake":1,"scaredswing":1,"scarefowl":1,"scatteredheat":1,"scatteredquiver":1,"scatteredstream":1,"scenicapparel":1,"scientificshirt":1,"scintillatingscissors":1,"scissorsstatement":1,"scrapesleep":1,"scratchsofa":1,"screechingfurniture":1,"screechingstocking":1,"scribbleson":1,"scrollservice":1,"scrubswim":1,"seashoresociety":1,"secondhandfall":1,"secretivesheep":1,"secretspiders":1,"secretturtle":1,"seedscissors":1,"seemlysuggestion":1,"selfishsea":1,"sendingspire":1,"sensorsmile":1,"separatesort":1,"seraphichorizon":1,"seraphicjubilee":1,"serendipityecho":1,"serenecascade":1,"serenepebble":1,"serenesurf":1,"serioussuit":1,"serpentshampoo":1,"settleshoes":1,"shadeship":1,"shaggytank":1,"shakyseat":1,"shakysurprise":1,"shakytaste":1,"shallowblade":1,"sharkskids":1,"sheargovernor":1,"shesubscriptions":1,"shinypond":1,"shirtsidewalk":1,"shiveringspot":1,"shiverscissors":1,"shockinggrass":1,"shockingship":1,"shredquiz":1,"shydinosaurs":1,"sierrakermit":1,"signaturepod":1,"siliconslow":1,"sillyscrew":1,"simplesidewalk":1,"simulateswing":1,"sincerebuffalo":1,"sincerepelican":1,"sinceresubstance":1,"sinkbooks":1,"sixscissors":1,"sizzlingsmoke":1,"slaysweater":1,"slimyscarf":1,"slinksuggestion":1,"smallershops":1,"smashshoe":1,"smilewound":1,"smilingcattle":1,"smilingswim":1,"smilingwaves":1,"smoggysongs":1,"smoggystation":1,"snacktoken":1,"snakemineral":1,"snakeslang":1,"sneakwind":1,"sneakystew":1,"snoresmile":1,"snowmentor":1,"soggysponge":1,"soggyzoo":1,"solarislabyrinth":1,"somberscarecrow":1,"sombersea":1,"sombersquirrel":1,"sombersticks":1,"sombersurprise":1,"soothingglade":1,"sophisticatedstove":1,"sordidsmile":1,"soresidewalk":1,"soresneeze":1,"sorethunder":1,"soretrain":1,"sortsail":1,"sortsummer":1,"sowlettuce":1,"spadelocket":1,"sparkgoal":1,"sparklingshelf":1,"specialscissors":1,"spellmist":1,"spellsalsa":1,"spiffymachine":1,"spirebaboon":1,"spookystitch":1,"spoonsilk":1,"spotlessstamp":1,"spottednoise":1,"springolive":1,"springsister":1,"springsnails":1,"sproutingbag":1,"sprydelta":1,"sprysummit":1,"spuriousair":1,"spuriousbase":1,"spurioussquirrel":1,"spuriousstranger":1,"spysubstance":1,"squalidscrew":1,"squeakzinc":1,"squealingturn":1,"stakingbasket":1,"stakingshock":1,"staleshow":1,"stalesummer":1,"starkscale":1,"startingcars":1,"statshunt":1,"statuesqueship":1,"stayaction":1,"steadycopper":1,"stealsteel":1,"steepscale":1,"steepsister":1,"stepcattle":1,"stepplane":1,"stepwisevideo":1,"stereoproxy":1,"stewspiders":1,"stiffstem":1,"stimulatingsneeze":1,"stingsquirrel":1,"stingyshoe":1,"stingyspoon":1,"stockingsleet":1,"stockingsneeze":1,"stomachscience":1,"stonechin":1,"stopstomach":1,"stormyachiever":1,"stormyfold":1,"strangeclocks":1,"strangersponge":1,"strangesink":1,"streetsort":1,"stretchsister":1,"stretchsneeze":1,"stretchsquirrel":1,"stripedbat":1,"strivesidewalk":1,"sturdysnail":1,"subletyoke":1,"sublimequartz":1,"subsequentswim":1,"substantialcarpenter":1,"substantialgrade":1,"succeedscene":1,"successfulscent":1,"suddensoda":1,"sugarfriction":1,"suggestionbridge":1,"summerobject":1,"sunshinegates":1,"superchichair":1,"superficialspring":1,"superviseshoes":1,"supportwaves":1,"suspectmark":1,"swellstocking":1,"swelteringsleep":1,"swingslip":1,"swordgoose":1,"syllablesight":1,"synonymousrule":1,"synonymoussticks":1,"synthesizescarecrow":1,"tackytrains":1,"tacojournal":1,"talltouch":1,"tangibleteam":1,"tangyamount":1,"tastelesstrees":1,"tastelesstrucks":1,"tastesnake":1,"tawdryson":1,"tearfulglass":1,"techconverter":1,"tediousbear":1,"tedioustooth":1,"teenytinycellar":1,"teenytinytongue":1,"telephoneapparatus":1,"tempertrick":1,"tempttalk":1,"temptteam":1,"terriblethumb":1,"terrifictooth":1,"testadmiral":1,"texturetrick":1,"therapeuticcars":1,"thickticket":1,"thicktrucks":1,"thingsafterthought":1,"thingstaste":1,"thinkitwice":1,"thirdrespect":1,"thirstytwig":1,"thomastorch":1,"thoughtlessknot":1,"thrivingmarketplace":1,"ticketaunt":1,"ticklesign":1,"tidymitten":1,"tightpowder":1,"tinyswans":1,"tinytendency":1,"tiredthroat":1,"toolcapital":1,"toomanyalts":1,"torpidtongue":1,"trackcaddie":1,"tradetooth":1,"trafficviews":1,"tranquilamulet":1,"tranquilarchipelago":1,"tranquilcan":1,"tranquilcanyon":1,"tranquilplume":1,"tranquilside":1,"tranquilveil":1,"tranquilveranda":1,"trappush":1,"treadbun":1,"tremendousearthquake":1,"tremendousplastic":1,"tremendoustime":1,"tritebadge":1,"tritethunder":1,"tritetongue":1,"troubledtail":1,"troubleshade":1,"truckstomatoes":1,"truculentrate":1,"tumbleicicle":1,"tuneupcoffee":1,"twistloss":1,"twistsweater":1,"typicalairplane":1,"ubiquitoussea":1,"ubiquitousyard":1,"ultravalid":1,"unablehope":1,"unaccountablecreator":1,"unaccountablepie":1,"unarmedindustry":1,"unbecominghall":1,"uncoveredexpert":1,"understoodocean":1,"unequalbrake":1,"unequaltrail":1,"unknowncontrol":1,"unknowncrate":1,"unknowntray":1,"untidyquestion":1,"untidyrice":1,"unusedstone":1,"unusualtitle":1,"unwieldyimpulse":1,"uppitytime":1,"uselesslumber":1,"validmemo":1,"vanfireworks":1,"vanishmemory":1,"velvetnova":1,"velvetquasar":1,"venomousvessel":1,"venusgloria":1,"verdantanswer":1,"verdantlabyrinth":1,"verdantloom":1,"verdantsculpture":1,"verseballs":1,"vibrantcelebration":1,"vibrantgale":1,"vibranthaven":1,"vibrantpact":1,"vibrantsundown":1,"vibranttalisman":1,"vibrantvale":1,"victoriousrequest":1,"virtualvincent":1,"vividcanopy":1,"vividfrost":1,"vividmeadow":1,"vividplume":1,"voicelessvein":1,"voidgoo":1,"volatileprofit":1,"waitingnumber":1,"wantingwindow":1,"warnwing":1,"washbanana":1,"wateryvan":1,"waterywave":1,"waterywrist":1,"wearbasin":1,"websitesdude":1,"wellgroomedapparel":1,"wellgroomedhydrant":1,"wellmadefrog":1,"westpalmweb":1,"whimsicalcanyon":1,"whimsicalgrove":1,"whineattempt":1,"whirlwealth":1,"whiskyqueue":1,"whisperingcascade":1,"whisperingcrib":1,"whisperingquasar":1,"whisperingsummit":1,"whispermeeting":1,"wildcommittee":1,"wirecomic":1,"wiredforcoffee":1,"wirypaste":1,"wistfulwaste":1,"wittypopcorn":1,"wittyshack":1,"workoperation":1,"worldlever":1,"worriednumber":1,"worriedwine":1,"wretchedfloor":1,"wrongpotato":1,"wrongwound":1,"wtaccesscontrol":1,"xovq5nemr":1,"yieldingwoman":1,"zbwp6ghm":1,"zephyrcatalyst":1,"zephyrlabyrinth":1,"zestyhorizon":1,"zestyrover":1,"zestywire":1,"zipperxray":1,"zonewedgeshaft":1},"net":{"2mdn":1,"2o7":1,"3gl":1,"a-mo":1,"acint":1,"adform":1,"adhigh":1,"admixer":1,"adobedc":1,"adspeed":1,"adverticum":1,"apicit":1,"appier":1,"akamaized":{"assets-momentum":1},"aticdn":1,"edgekey":{"au":1,"ca":1,"ch":1,"cn":1,"com-v1":1,"es":1,"ihg":1,"in":1,"io":1,"it":1,"jp":1,"net":1,"org":1,"com":{"scene7":1},"uk-v1":1,"uk":1},"azure":1,"azurefd":1,"bannerflow":1,"bf-tools":1,"bidswitch":1,"bitsngo":1,"blueconic":1,"boldapps":1,"buysellads":1,"cachefly":1,"cedexis":1,"certona":1,"confiant-integrations":1,"contentsquare":1,"criteo":1,"crwdcntrl":1,"cloudfront":{"d1af033869koo7":1,"d1cr9zxt7u0sgu":1,"d1s87id6169zda":1,"d1vg5xiq7qffdj":1,"d1y068gyog18cq":1,"d214hhm15p4t1d":1,"d21gpk1vhmjuf5":1,"d2zah9y47r7bi2":1,"d38b8me95wjkbc":1,"d38xvr37kwwhcm":1,"d3fv2pqyjay52z":1,"d3i4yxtzktqr9n":1,"d3odp2r1osuwn0":1,"d5yoctgpv4cpx":1,"d6tizftlrpuof":1,"dbukjj6eu5tsf":1,"dn0qt3r0xannq":1,"dsh7ky7308k4b":1,"d2g3ekl4mwm40k":1},"demdex":1,"dotmetrics":1,"doubleclick":1,"durationmedia":1,"e-planning":1,"edgecastcdn":1,"emsecure":1,"episerver":1,"esm1":1,"eulerian":1,"everestjs":1,"everesttech":1,"eyeota":1,"ezoic":1,"fastly":{"global":{"shared":{"f2":1},"sni":{"j":1}},"map":{"prisa-us-eu":1,"scribd":1},"ssl":{"global":{"qognvtzku-x":1}}},"facebook":1,"fastclick":1,"fonts":1,"azureedge":{"fp-cdn":1,"sdtagging":1},"fuseplatform":1,"fwmrm":1,"go-mpulse":1,"hadronid":1,"hs-analytics":1,"hsleadflows":1,"im-apps":1,"impervadns":1,"iocnt":1,"iprom":1,"jsdelivr":1,"kanade-ad":1,"krxd":1,"line-scdn":1,"listhub":1,"livecom":1,"livedoor":1,"liveperson":1,"lkqd":1,"llnwd":1,"lpsnmedia":1,"magnetmail":1,"marketo":1,"maxymiser":1,"media":1,"microad":1,"mobon":1,"monetate":1,"mxptint":1,"myfonts":1,"myvisualiq":1,"naver":1,"nr-data":1,"ojrq":1,"omtrdc":1,"onecount":1,"openx":1,"openxcdn":1,"opta":1,"owneriq":1,"pages02":1,"pages03":1,"pages04":1,"pages05":1,"pages06":1,"pages08":1,"pingdom":1,"pmdstatic":1,"popads":1,"popcash":1,"primecaster":1,"pro-market":1,"akamaihd":{"pxlclnmdecom-a":1},"rfihub":1,"sancdn":1,"sc-static":1,"semasio":1,"sensic":1,"sexad":1,"smaato":1,"spreadshirts":1,"storygize":1,"tfaforms":1,"trackcmp":1,"trackedlink":1,"tradetracker":1,"truste-svc":1,"uuidksinc":1,"viafoura":1,"visilabs":1,"visx":1,"w55c":1,"wdsvc":1,"witglobal":1,"yandex":1,"yastatic":1,"yieldlab":1,"zencdn":1,"zucks":1,"opencmp":1,"azurewebsites":{"app-fnsp-matomo-analytics-prod":1},"ad-delivery":1,"chartbeat":1,"msecnd":1,"cloudfunctions":{"us-central1-adaptive-growth":1},"eviltracker":1},"co":{"6sc":1,"ayads":1,"getlasso":1,"idio":1,"increasingly":1,"jads":1,"nanorep":1,"nc0":1,"pcdn":1,"prmutv":1,"resetdigital":1,"t":1,"tctm":1,"zip":1},"gt":{"ad":1},"ru":{"adfox":1,"adriver":1,"digitaltarget":1,"mail":1,"mindbox":1,"rambler":1,"rutarget":1,"sape":1,"smi2":1,"tns-counter":1,"top100":1,"ulogin":1,"yandex":1,"yadro":1},"jp":{"adingo":1,"admatrix":1,"auone":1,"co":{"dmm":1,"i-mobile":1,"rakuten":1,"yahoo":1},"fout":1,"genieesspv":1,"gmossp-sp":1,"gsspat":1,"gssprt":1,"ne":{"hatena":1},"i2i":1,"impact-ad":1,"microad":1,"nakanohito":1,"r10s":1,"reemo-ad":1,"rtoaster":1,"shinobi":1,"team-rec":1,"uncn":1,"yimg":1,"yjtag":1},"pl":{"adocean":1,"gemius":1,"nsaudience":1,"onet":1,"salesmanago":1,"wp":1},"pro":{"adpartner":1,"piwik":1,"usocial":1},"de":{"adscale":1,"auswaertiges-amt":1,"fiduciagad":1,"ioam":1,"itzbund":1,"vgwort":1,"werk21system":1},"re":{"adsco":1},"info":{"adxbid":1,"bitrix":1,"navistechnologies":1,"usergram":1,"webantenna":1},"tv":{"affec":1,"attn":1,"iris":1,"ispot":1,"samba":1,"teads":1,"twitch":1,"videohub":1},"dev":{"amazon":1},"us":{"amung":1,"samplicio":1,"slgnt":1,"trkn":1,"owlsr":1},"media":{"andbeyond":1,"nextday":1,"townsquare":1,"underdog":1},"link":{"app":1},"cloud":{"avct":1,"egain":1,"matomo":1},"delivery":{"ay":1,"monu":1},"ly":{"bit":1},"br":{"com":{"btg360":1,"clearsale":1,"jsuol":1,"shopconvert":1,"shoptarget":1,"soclminer":1},"org":{"ivcbrasil":1}},"ch":{"ch":1,"da-services":1,"google":1},"me":{"channel":1,"contentexchange":1,"grow":1,"line":1,"loopme":1,"t":1},"ms":{"clarity":1},"my":{"cnt":1},"se":{"codigo":1},"to":{"cpx":1,"tawk":1},"chat":{"crisp":1,"gorgias":1},"fr":{"d-bi":1,"open-system":1,"weborama":1},"uk":{"co":{"dailymail":1,"hsbc":1}},"gov":{"dhs":1},"ai":{"e-volution":1,"hybrid":1,"m2":1,"nrich":1,"wknd":1},"be":{"geoedge":1},"au":{"com":{"google":1,"news":1,"nine":1,"zipmoney":1,"telstra":1}},"stream":{"ibclick":1},"cz":{"imedia":1,"seznam":1,"trackad":1},"app":{"infusionsoft":1,"permutive":1,"shop":1},"tech":{"ingage":1,"primis":1},"eu":{"kameleoon":1,"medallia":1,"media01":1,"ocdn":1,"rqtrk":1,"slgnt":1},"fi":{"kesko":1,"simpli":1},"live":{"lura":1},"services":{"marketingautomation":1},"sg":{"mediacorp":1},"bi":{"newsroom":1},"fm":{"pdst":1},"ad":{"pixel":1},"xyz":{"playground":1},"it":{"plug":1,"repstatic":1},"cc":{"popin":1},"network":{"pub":1},"nl":{"rijksoverheid":1},"fyi":{"sda":1},"es":{"socy":1},"im":{"spot":1},"market":{"spotim":1},"am":{"tru":1},"no":{"uio":1,"medietall":1},"at":{"waust":1},"pe":{"shop":1},"ca":{"bc":{"gov":1}},"gg":{"clean":1},"example":{"ad-company":1},"site":{"ad-company":1,"third-party":{"bad":1,"broken":1}},"pw":{"5mcwl":1,"fvl1f":1,"h78xb":1,"i9w8p":1,"k54nw":1,"tdzvm":1,"tzwaw":1,"vq1qi":1,"zlp6s":1},"pub":{"admiral":1}}; output.bundledConfig = data; - return output + return output; } /** @@ -601,20 +612,24 @@ * @param {string[]} platformSpecificFeatures * @returns {string[]} */ - function computeEnabledFeatures (data, topLevelHostname, platformVersion, platformSpecificFeatures = []) { + function computeEnabledFeatures(data, topLevelHostname, platformVersion, platformSpecificFeatures = []) { const remoteFeatureNames = Object.keys(data.features); - const platformSpecificFeaturesNotInRemoteConfig = platformSpecificFeatures.filter((featureName) => !remoteFeatureNames.includes(featureName)); - const enabledFeatures = remoteFeatureNames.filter((featureName) => { - const feature = data.features[featureName]; - // Check that the platform supports minSupportedVersion checks and that the feature has a minSupportedVersion - if (feature.minSupportedVersion && platformVersion) { - if (!isSupportedVersion(feature.minSupportedVersion, platformVersion)) { - return false + const platformSpecificFeaturesNotInRemoteConfig = platformSpecificFeatures.filter( + (featureName) => !remoteFeatureNames.includes(featureName), + ); + const enabledFeatures = remoteFeatureNames + .filter((featureName) => { + const feature = data.features[featureName]; + // Check that the platform supports minSupportedVersion checks and that the feature has a minSupportedVersion + if (feature.minSupportedVersion && platformVersion) { + if (!isSupportedVersion(feature.minSupportedVersion, platformVersion)) { + return false; + } } - } - return feature.state === 'enabled' && !isUnprotectedDomain(topLevelHostname, feature.exceptions) - }).concat(platformSpecificFeaturesNotInRemoteConfig); // only disable platform specific features if it's explicitly disabled in remote config - return enabledFeatures + return feature.state === 'enabled' && !isUnprotectedDomain(topLevelHostname, feature.exceptions); + }) + .concat(platformSpecificFeaturesNotInRemoteConfig); // only disable platform specific features if it's explicitly disabled in remote config + return enabledFeatures; } /** @@ -623,44 +638,47 @@ * @param {string[]} enabledFeatures * @returns {Record} */ - function parseFeatureSettings (data, enabledFeatures) { + function parseFeatureSettings(data, enabledFeatures) { /** @type {Record} */ const featureSettings = {}; const remoteFeatureNames = Object.keys(data.features); remoteFeatureNames.forEach((featureName) => { if (!enabledFeatures.includes(featureName)) { - return + return; } featureSettings[featureName] = data.features[featureName].settings; }); - return featureSettings + return featureSettings; } - function isGloballyDisabled (args) { - return args.site.allowlisted || args.site.isBroken + function isGloballyDisabled(args) { + return args.site.allowlisted || args.site.isBroken; } const windowsSpecificFeatures = ['windowsPermissionUsage']; - function isWindowsSpecificFeature (featureName) { - return windowsSpecificFeatures.includes(featureName) + function isWindowsSpecificFeature(featureName) { + return windowsSpecificFeatures.includes(featureName); } - function createCustomEvent (eventName, eventDetail) { + function createCustomEvent(eventName, eventDetail) { // @ts-expect-error - possibly null - return new OriginalCustomEvent(eventName, eventDetail) + return new OriginalCustomEvent(eventName, eventDetail); } /** @deprecated */ - function legacySendMessage (messageType, options) { + function legacySendMessage(messageType, options) { // FF & Chrome - return originalWindowDispatchEvent && originalWindowDispatchEvent(createCustomEvent('sendMessageProxy' + messageSecret, { detail: { messageType, options } })) + return ( + originalWindowDispatchEvent && + originalWindowDispatchEvent(createCustomEvent('sendMessageProxy' + messageSecret, { detail: { messageType, options } })) + ); // TBD other platforms } - const baseFeatures = /** @type {const} */([ + const baseFeatures = /** @type {const} */ ([ 'fingerprintingAudio', 'fingerprintingBattery', 'fingerprintingCanvas', @@ -672,10 +690,11 @@ 'fingerprintingTemporaryStorage', 'navigatorInterface', 'elementHiding', - 'exceptionHandler' + 'exceptionHandler', + 'apiManipulation', ]); - const otherFeatures = /** @type {const} */([ + const otherFeatures = /** @type {const} */ ([ 'clickToLoad', 'cookie', 'duckPlayer', @@ -685,66 +704,28 @@ 'brokerProtection', 'performanceMetrics', 'breakageReporting', - 'autofillPasswordImport' + 'autofillPasswordImport', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ const platformSupport = { - apple: [ - 'webCompat', - ...baseFeatures - ], - 'apple-isolated': [ - 'duckPlayer', - 'brokerProtection', - 'performanceMetrics', - 'clickToLoad' - ], - android: [ - ...baseFeatures, - 'webCompat', - 'clickToLoad', - 'breakageReporting', - 'duckPlayer' - ], - 'android-autofill-password-import': [ - 'autofillPasswordImport' - ], - windows: [ - 'cookie', - ...baseFeatures, - 'windowsPermissionUsage', - 'duckPlayer', - 'brokerProtection', - 'breakageReporting' - ], - firefox: [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - chrome: [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - 'chrome-mv3': [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - integration: [ - ...baseFeatures, - ...otherFeatures - ] + apple: ['webCompat', ...baseFeatures], + 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad'], + android: [...baseFeatures, 'webCompat', 'clickToLoad', 'breakageReporting', 'duckPlayer'], + 'android-autofill-password-import': ['autofillPasswordImport'], + windows: ['cookie', ...baseFeatures, 'windowsPermissionUsage', 'duckPlayer', 'brokerProtection', 'breakageReporting'], + firefox: ['cookie', ...baseFeatures, 'clickToLoad'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], + integration: [...baseFeatures, ...otherFeatures], }; /** * Performance monitor, holds reference to PerformanceMark instances. */ class PerformanceMonitor { - constructor () { + constructor() { this.marks = []; } @@ -753,16 +734,16 @@ * @param {string} name * @returns {PerformanceMark} */ - mark (name) { + mark(name) { const mark = new PerformanceMark(name); this.marks.push(mark); - return mark + return mark; } /** * Measure all performance markers */ - measureAll () { + measureAll() { this.marks.forEach((mark) => { mark.measure(); }); @@ -777,523 +758,560 @@ /** * @param {string} name */ - constructor (name) { + constructor(name) { this.name = name; performance.mark(this.name + 'Start'); } - end () { + end() { performance.mark(this.name + 'End'); } - measure () { + measure() { performance.measure(this.name, this.name + 'Start', this.name + 'End'); } } // @ts-nocheck - const sjcl = (() => { - /*jslint indent: 2, bitwise: false, nomen: false, plusplus: false, white: false, regexp: false */ - /*global document, window, escape, unescape, module, require, Uint32Array */ - - /** - * The Stanford Javascript Crypto Library, top-level namespace. - * @namespace - */ - var sjcl = { - /** - * Symmetric ciphers. - * @namespace - */ - cipher: {}, - - /** - * Hash functions. Right now only SHA256 is implemented. - * @namespace - */ - hash: {}, - - /** - * Key exchange functions. Right now only SRP is implemented. - * @namespace - */ - keyexchange: {}, - - /** - * Cipher modes of operation. - * @namespace - */ - mode: {}, - - /** - * Miscellaneous. HMAC and PBKDF2. - * @namespace - */ - misc: {}, - - /** - * Bit array encoders and decoders. - * @namespace - * - * @description - * The members of this namespace are functions which translate between - * SJCL's bitArrays and other objects (usually strings). Because it - * isn't always clear which direction is encoding and which is decoding, - * the method names are "fromBits" and "toBits". - */ - codec: {}, - - /** - * Exceptions. - * @namespace - */ - exception: { - /** - * Ciphertext is corrupt. - * @constructor + const sjcl = (() => { + /*jslint indent: 2, bitwise: false, nomen: false, plusplus: false, white: false, regexp: false */ + /*global document, window, escape, unescape, module, require, Uint32Array */ + + /** + * The Stanford Javascript Crypto Library, top-level namespace. + * @namespace */ - corrupt: function(message) { - this.toString = function() { return "CORRUPT: "+this.message; }; - this.message = message; - }, - + var sjcl = { + /** + * Symmetric ciphers. + * @namespace + */ + cipher: {}, + + /** + * Hash functions. Right now only SHA256 is implemented. + * @namespace + */ + hash: {}, + + /** + * Key exchange functions. Right now only SRP is implemented. + * @namespace + */ + keyexchange: {}, + + /** + * Cipher modes of operation. + * @namespace + */ + mode: {}, + + /** + * Miscellaneous. HMAC and PBKDF2. + * @namespace + */ + misc: {}, + + /** + * Bit array encoders and decoders. + * @namespace + * + * @description + * The members of this namespace are functions which translate between + * SJCL's bitArrays and other objects (usually strings). Because it + * isn't always clear which direction is encoding and which is decoding, + * the method names are "fromBits" and "toBits". + */ + codec: {}, + + /** + * Exceptions. + * @namespace + */ + exception: { + /** + * Ciphertext is corrupt. + * @constructor + */ + corrupt: function (message) { + this.toString = function () { + return 'CORRUPT: ' + this.message; + }; + this.message = message; + }, + + /** + * Invalid parameter. + * @constructor + */ + invalid: function (message) { + this.toString = function () { + return 'INVALID: ' + this.message; + }; + this.message = message; + }, + + /** + * Bug or missing feature in SJCL. + * @constructor + */ + bug: function (message) { + this.toString = function () { + return 'BUG: ' + this.message; + }; + this.message = message; + }, + + /** + * Something isn't ready. + * @constructor + */ + notReady: function (message) { + this.toString = function () { + return 'NOT READY: ' + this.message; + }; + this.message = message; + }, + }, + }; + /** @fileOverview Arrays of bits, encoded as arrays of Numbers. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + /** - * Invalid parameter. - * @constructor + * Arrays of bits, encoded as arrays of Numbers. + * @namespace + * @description + *

+ * These objects are the currency accepted by SJCL's crypto functions. + *

+ * + *

+ * Most of our crypto primitives operate on arrays of 4-byte words internally, + * but many of them can take arguments that are not a multiple of 4 bytes. + * This library encodes arrays of bits (whose size need not be a multiple of 8 + * bits) as arrays of 32-bit words. The bits are packed, big-endian, into an + * array of words, 32 bits at a time. Since the words are double-precision + * floating point numbers, they fit some extra data. We use this (in a private, + * possibly-changing manner) to encode the number of bits actually present + * in the last word of the array. + *

+ * + *

+ * Because bitwise ops clear this out-of-band data, these arrays can be passed + * to ciphers like AES which want arrays of words. + *

*/ - invalid: function(message) { - this.toString = function() { return "INVALID: "+this.message; }; - this.message = message; - }, - + sjcl.bitArray = { + /** + * Array slices in units of bits. + * @param {bitArray} a The array to slice. + * @param {Number} bstart The offset to the start of the slice, in bits. + * @param {Number} bend The offset to the end of the slice, in bits. If this is undefined, + * slice until the end of the array. + * @return {bitArray} The requested slice. + */ + bitSlice: function (a, bstart, bend) { + a = sjcl.bitArray._shiftRight(a.slice(bstart / 32), 32 - (bstart & 31)).slice(1); + return bend === undefined ? a : sjcl.bitArray.clamp(a, bend - bstart); + }, + + /** + * Extract a number packed into a bit array. + * @param {bitArray} a The array to slice. + * @param {Number} bstart The offset to the start of the slice, in bits. + * @param {Number} blength The length of the number to extract. + * @return {Number} The requested slice. + */ + extract: function (a, bstart, blength) { + // FIXME: this Math.floor is not necessary at all, but for some reason + // seems to suppress a bug in the Chromium JIT. + var x, + sh = Math.floor((-bstart - blength) & 31); + if (((bstart + blength - 1) ^ bstart) & -32) { + // it crosses a boundary + x = (a[(bstart / 32) | 0] << (32 - sh)) ^ (a[(bstart / 32 + 1) | 0] >>> sh); + } else { + // within a single word + x = a[(bstart / 32) | 0] >>> sh; + } + return x & ((1 << blength) - 1); + }, + + /** + * Concatenate two bit arrays. + * @param {bitArray} a1 The first array. + * @param {bitArray} a2 The second array. + * @return {bitArray} The concatenation of a1 and a2. + */ + concat: function (a1, a2) { + if (a1.length === 0 || a2.length === 0) { + return a1.concat(a2); + } + + var last = a1[a1.length - 1], + shift = sjcl.bitArray.getPartial(last); + if (shift === 32) { + return a1.concat(a2); + } else { + return sjcl.bitArray._shiftRight(a2, shift, last | 0, a1.slice(0, a1.length - 1)); + } + }, + + /** + * Find the length of an array of bits. + * @param {bitArray} a The array. + * @return {Number} The length of a, in bits. + */ + bitLength: function (a) { + var l = a.length, + x; + if (l === 0) { + return 0; + } + x = a[l - 1]; + return (l - 1) * 32 + sjcl.bitArray.getPartial(x); + }, + + /** + * Truncate an array. + * @param {bitArray} a The array. + * @param {Number} len The length to truncate to, in bits. + * @return {bitArray} A new array, truncated to len bits. + */ + clamp: function (a, len) { + if (a.length * 32 < len) { + return a; + } + a = a.slice(0, Math.ceil(len / 32)); + var l = a.length; + len = len & 31; + if (l > 0 && len) { + a[l - 1] = sjcl.bitArray.partial(len, a[l - 1] & (0x80000000 >> (len - 1)), 1); + } + return a; + }, + + /** + * Make a partial word for a bit array. + * @param {Number} len The number of bits in the word. + * @param {Number} x The bits. + * @param {Number} [_end=0] Pass 1 if x has already been shifted to the high side. + * @return {Number} The partial word. + */ + partial: function (len, x, _end) { + if (len === 32) { + return x; + } + return (_end ? x | 0 : x << (32 - len)) + len * 0x10000000000; + }, + + /** + * Get the number of bits used by a partial word. + * @param {Number} x The partial word. + * @return {Number} The number of bits used by the partial word. + */ + getPartial: function (x) { + return Math.round(x / 0x10000000000) || 32; + }, + + /** + * Compare two arrays for equality in a predictable amount of time. + * @param {bitArray} a The first array. + * @param {bitArray} b The second array. + * @return {boolean} true if a == b; false otherwise. + */ + equal: function (a, b) { + if (sjcl.bitArray.bitLength(a) !== sjcl.bitArray.bitLength(b)) { + return false; + } + var x = 0, + i; + for (i = 0; i < a.length; i++) { + x |= a[i] ^ b[i]; + } + return x === 0; + }, + + /** Shift an array right. + * @param {bitArray} a The array to shift. + * @param {Number} shift The number of bits to shift. + * @param {Number} [carry=0] A byte to carry in + * @param {bitArray} [out=[]] An array to prepend to the output. + * @private + */ + _shiftRight: function (a, shift, carry, out) { + var i, + last2 = 0, + shift2; + if (out === undefined) { + out = []; + } + + for (; shift >= 32; shift -= 32) { + out.push(carry); + carry = 0; + } + if (shift === 0) { + return out.concat(a); + } + + for (i = 0; i < a.length; i++) { + out.push(carry | (a[i] >>> shift)); + carry = a[i] << (32 - shift); + } + last2 = a.length ? a[a.length - 1] : 0; + shift2 = sjcl.bitArray.getPartial(last2); + out.push(sjcl.bitArray.partial((shift + shift2) & 31, shift + shift2 > 32 ? carry : out.pop(), 1)); + return out; + }, + + /** xor a block of 4 words together. + * @private + */ + _xor4: function (x, y) { + return [x[0] ^ y[0], x[1] ^ y[1], x[2] ^ y[2], x[3] ^ y[3]]; + }, + + /** byteswap a word array inplace. + * (does not handle partial words) + * @param {sjcl.bitArray} a word array + * @return {sjcl.bitArray} byteswapped array + */ + byteswapM: function (a) { + var i, + v, + m = 0xff00; + for (i = 0; i < a.length; ++i) { + v = a[i]; + a[i] = (v >>> 24) | ((v >>> 8) & m) | ((v & m) << 8) | (v << 24); + } + return a; + }, + }; + /** @fileOverview Bit array codec implementations. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + /** - * Bug or missing feature in SJCL. - * @constructor + * UTF-8 strings + * @namespace + */ + sjcl.codec.utf8String = { + /** Convert from a bitArray to a UTF-8 string. */ + fromBits: function (arr) { + var out = '', + bl = sjcl.bitArray.bitLength(arr), + i, + tmp; + for (i = 0; i < bl / 8; i++) { + if ((i & 3) === 0) { + tmp = arr[i / 4]; + } + out += String.fromCharCode(((tmp >>> 8) >>> 8) >>> 8); + tmp <<= 8; + } + return decodeURIComponent(escape(out)); + }, + + /** Convert from a UTF-8 string to a bitArray. */ + toBits: function (str) { + str = unescape(encodeURIComponent(str)); + var out = [], + i, + tmp = 0; + for (i = 0; i < str.length; i++) { + tmp = (tmp << 8) | str.charCodeAt(i); + if ((i & 3) === 3) { + out.push(tmp); + tmp = 0; + } + } + if (i & 3) { + out.push(sjcl.bitArray.partial(8 * (i & 3), tmp)); + } + return out; + }, + }; + /** @fileOverview Bit array codec implementations. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh */ - bug: function(message) { - this.toString = function() { return "BUG: "+this.message; }; - this.message = message; - }, /** - * Something isn't ready. + * Hexadecimal + * @namespace + */ + sjcl.codec.hex = { + /** Convert from a bitArray to a hex string. */ + fromBits: function (arr) { + var out = '', + i; + for (i = 0; i < arr.length; i++) { + out += ((arr[i] | 0) + 0xf00000000000).toString(16).substr(4); + } + return out.substr(0, sjcl.bitArray.bitLength(arr) / 4); //.replace(/(.{8})/g, "$1 "); + }, + /** Convert from a hex string to a bitArray. */ + toBits: function (str) { + var i, + out = [], + len; + str = str.replace(/\s|0x/g, ''); + len = str.length; + str = str + '00000000'; + for (i = 0; i < str.length; i += 8) { + out.push(parseInt(str.substr(i, 8), 16) ^ 0); + } + return sjcl.bitArray.clamp(out, len * 4); + }, + }; + + /** @fileOverview Javascript SHA-256 implementation. + * + * An older version of this implementation is available in the public + * domain, but this one is (c) Emily Stark, Mike Hamburg, Dan Boneh, + * Stanford University 2008-2010 and BSD-licensed for liability + * reasons. + * + * Special thanks to Aldo Cortesi for pointing out several bugs in + * this code. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + + /** + * Context for a SHA-256 operation in progress. * @constructor */ - notReady: function(message) { - this.toString = function() { return "NOT READY: "+this.message; }; - this.message = message; - } - } - }; - /** @fileOverview Arrays of bits, encoded as arrays of Numbers. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + sjcl.hash.sha256 = function (hash) { + if (!this._key[0]) { + this._precompute(); + } + if (hash) { + this._h = hash._h.slice(0); + this._buffer = hash._buffer.slice(0); + this._length = hash._length; + } else { + this.reset(); + } + }; - /** - * Arrays of bits, encoded as arrays of Numbers. - * @namespace - * @description - *

- * These objects are the currency accepted by SJCL's crypto functions. - *

- * - *

- * Most of our crypto primitives operate on arrays of 4-byte words internally, - * but many of them can take arguments that are not a multiple of 4 bytes. - * This library encodes arrays of bits (whose size need not be a multiple of 8 - * bits) as arrays of 32-bit words. The bits are packed, big-endian, into an - * array of words, 32 bits at a time. Since the words are double-precision - * floating point numbers, they fit some extra data. We use this (in a private, - * possibly-changing manner) to encode the number of bits actually present - * in the last word of the array. - *

- * - *

- * Because bitwise ops clear this out-of-band data, these arrays can be passed - * to ciphers like AES which want arrays of words. - *

- */ - sjcl.bitArray = { - /** - * Array slices in units of bits. - * @param {bitArray} a The array to slice. - * @param {Number} bstart The offset to the start of the slice, in bits. - * @param {Number} bend The offset to the end of the slice, in bits. If this is undefined, - * slice until the end of the array. - * @return {bitArray} The requested slice. - */ - bitSlice: function (a, bstart, bend) { - a = sjcl.bitArray._shiftRight(a.slice(bstart/32), 32 - (bstart & 31)).slice(1); - return (bend === undefined) ? a : sjcl.bitArray.clamp(a, bend-bstart); - }, - - /** - * Extract a number packed into a bit array. - * @param {bitArray} a The array to slice. - * @param {Number} bstart The offset to the start of the slice, in bits. - * @param {Number} blength The length of the number to extract. - * @return {Number} The requested slice. - */ - extract: function(a, bstart, blength) { - // FIXME: this Math.floor is not necessary at all, but for some reason - // seems to suppress a bug in the Chromium JIT. - var x, sh = Math.floor((-bstart-blength) & 31); - if ((bstart + blength - 1 ^ bstart) & -32) { - // it crosses a boundary - x = (a[bstart/32|0] << (32 - sh)) ^ (a[bstart/32+1|0] >>> sh); - } else { - // within a single word - x = a[bstart/32|0] >>> sh; - } - return x & ((1< 0 && len) { - a[l-1] = sjcl.bitArray.partial(len, a[l-1] & 0x80000000 >> (len-1), 1); - } - return a; - }, - - /** - * Make a partial word for a bit array. - * @param {Number} len The number of bits in the word. - * @param {Number} x The bits. - * @param {Number} [_end=0] Pass 1 if x has already been shifted to the high side. - * @return {Number} The partial word. - */ - partial: function (len, x, _end) { - if (len === 32) { return x; } - return (_end ? x|0 : x << (32-len)) + len * 0x10000000000; - }, - - /** - * Get the number of bits used by a partial word. - * @param {Number} x The partial word. - * @return {Number} The number of bits used by the partial word. - */ - getPartial: function (x) { - return Math.round(x/0x10000000000) || 32; - }, - - /** - * Compare two arrays for equality in a predictable amount of time. - * @param {bitArray} a The first array. - * @param {bitArray} b The second array. - * @return {boolean} true if a == b; false otherwise. - */ - equal: function (a, b) { - if (sjcl.bitArray.bitLength(a) !== sjcl.bitArray.bitLength(b)) { - return false; - } - var x = 0, i; - for (i=0; i= 32; shift -= 32) { - out.push(carry); - carry = 0; - } - if (shift === 0) { - return out.concat(a); - } - - for (i=0; i>>shift); - carry = a[i] << (32-shift); - } - last2 = a.length ? a[a.length-1] : 0; - shift2 = sjcl.bitArray.getPartial(last2); - out.push(sjcl.bitArray.partial(shift+shift2 & 31, (shift + shift2 > 32) ? carry : out.pop(),1)); - return out; - }, - - /** xor a block of 4 words together. - * @private - */ - _xor4: function(x,y) { - return [x[0]^y[0],x[1]^y[1],x[2]^y[2],x[3]^y[3]]; - }, - - /** byteswap a word array inplace. - * (does not handle partial words) - * @param {sjcl.bitArray} a word array - * @return {sjcl.bitArray} byteswapped array - */ - byteswapM: function(a) { - var i, v, m = 0xff00; - for (i = 0; i < a.length; ++i) { - v = a[i]; - a[i] = (v >>> 24) | ((v >>> 8) & m) | ((v & m) << 8) | (v << 24); - } - return a; - } - }; - /** @fileOverview Bit array codec implementations. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + /** + * Hash a string or an array of words. + * @static + * @param {bitArray|String} data the data to hash. + * @return {bitArray} The hash value, an array of 16 big-endian words. + */ + sjcl.hash.sha256.hash = function (data) { + return new sjcl.hash.sha256().update(data).finalize(); + }; - /** - * UTF-8 strings - * @namespace - */ - sjcl.codec.utf8String = { - /** Convert from a bitArray to a UTF-8 string. */ - fromBits: function (arr) { - var out = "", bl = sjcl.bitArray.bitLength(arr), i, tmp; - for (i=0; i>> 8 >>> 8 >>> 8); - tmp <<= 8; - } - return decodeURIComponent(escape(out)); - }, - - /** Convert from a UTF-8 string to a bitArray. */ - toBits: function (str) { - str = unescape(encodeURIComponent(str)); - var out = [], i, tmp=0; - for (i=0; i 9007199254740991) { + throw new sjcl.exception.invalid('Cannot hash more than 2^53 - 1 bits'); + } + + if (typeof Uint32Array !== 'undefined') { + var c = new Uint32Array(b); + var j = 0; + for (i = 512 + ol - ((512 + ol) & 511); i <= nl; i += 512) { + this._block(c.subarray(16 * j, 16 * (j + 1))); + j += 1; + } + b.splice(0, 16 * j); + } else { + for (i = 512 + ol - ((512 + ol) & 511); i <= nl; i += 512) { + this._block(b.splice(0, 16)); + } + } + return this; + }, - /** - * Hash a string or an array of words. - * @static - * @param {bitArray|String} data the data to hash. - * @return {bitArray} The hash value, an array of 16 big-endian words. - */ - sjcl.hash.sha256.hash = function (data) { - return (new sjcl.hash.sha256()).update(data).finalize(); - }; + /** + * Complete hashing and output the hash value. + * @return {bitArray} The hash value, an array of 8 big-endian words. + */ + finalize: function () { + var i, + b = this._buffer, + h = this._h; - sjcl.hash.sha256.prototype = { - /** - * The hash's block size, in bits. - * @constant - */ - blockSize: 512, - - /** - * Reset the hash state. - * @return this - */ - reset:function () { - this._h = this._init.slice(0); - this._buffer = []; - this._length = 0; - return this; - }, - - /** - * Input several words to the hash. - * @param {bitArray|String} data the data to hash. - * @return this - */ - update: function (data) { - if (typeof data === "string") { - data = sjcl.codec.utf8String.toBits(data); - } - var i, b = this._buffer = sjcl.bitArray.concat(this._buffer, data), - ol = this._length, - nl = this._length = ol + sjcl.bitArray.bitLength(data); - if (nl > 9007199254740991){ - throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits"); - } - - if (typeof Uint32Array !== 'undefined') { - var c = new Uint32Array(b); - var j = 0; - for (i = 512+ol - ((512+ol) & 511); i <= nl; i+= 512) { - this._block(c.subarray(16 * j, 16 * (j+1))); - j += 1; - } - b.splice(0, 16 * j); - } else { - for (i = 512+ol - ((512+ol) & 511); i <= nl; i+= 512) { - this._block(b.splice(0,16)); - } - } - return this; - }, - - /** - * Complete hashing and output the hash value. - * @return {bitArray} The hash value, an array of 8 big-endian words. - */ - finalize:function () { - var i, b = this._buffer, h = this._h; - - // Round out and push the buffer - b = sjcl.bitArray.concat(b, [sjcl.bitArray.partial(1,1)]); - - // Round out the buffer to a multiple of 16 words, less the 2 length words. - for (i = b.length + 2; i & 15; i++) { - b.push(0); - } - - // append the length - b.push(Math.floor(this._length / 0x100000000)); - b.push(this._length | 0); - - while (b.length) { - this._block(b.splice(0,16)); - } - - this.reset(); - return h; - }, - - /** - * The SHA-256 initialization vector, to be precomputed. - * @private - */ - _init:[], - /* + // Round out and push the buffer + b = sjcl.bitArray.concat(b, [sjcl.bitArray.partial(1, 1)]); + + // Round out the buffer to a multiple of 16 words, less the 2 length words. + for (i = b.length + 2; i & 15; i++) { + b.push(0); + } + + // append the length + b.push(Math.floor(this._length / 0x100000000)); + b.push(this._length | 0); + + while (b.length) { + this._block(b.splice(0, 16)); + } + + this.reset(); + return h; + }, + + /** + * The SHA-256 initialization vector, to be precomputed. + * @private + */ + _init: [], + /* _init:[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19], */ - - /** - * The SHA-256 hash key, to be precomputed. - * @private - */ - _key:[], - /* + + /** + * The SHA-256 hash key, to be precomputed. + * @private + */ + _key: [], + /* _key: [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, @@ -1305,162 +1323,192 @@ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2], */ + /** + * Function to precompute _init and _key. + * @private + */ + _precompute: function () { + var i = 0, + prime = 2, + factor, + isPrime; + + function frac(x) { + return ((x - Math.floor(x)) * 0x100000000) | 0; + } + + for (; i < 64; prime++) { + isPrime = true; + for (factor = 2; factor * factor <= prime; factor++) { + if (prime % factor === 0) { + isPrime = false; + break; + } + } + if (isPrime) { + if (i < 8) { + this._init[i] = frac(Math.pow(prime, 1 / 2)); + } + this._key[i] = frac(Math.pow(prime, 1 / 3)); + i++; + } + } + }, - /** - * Function to precompute _init and _key. - * @private - */ - _precompute: function () { - var i = 0, prime = 2, factor, isPrime; + /** + * Perform one cycle of SHA-256. + * @param {Uint32Array|bitArray} w one block of words. + * @private + */ + _block: function (w) { + var i, + tmp, + a, + b, + h = this._h, + k = this._key, + h0 = h[0], + h1 = h[1], + h2 = h[2], + h3 = h[3], + h4 = h[4], + h5 = h[5], + h6 = h[6], + h7 = h[7]; + + /* Rationale for placement of |0 : + * If a value can overflow is original 32 bits by a factor of more than a few + * million (2^23 ish), there is a possibility that it might overflow the + * 53-bit mantissa and lose precision. + * + * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that + * propagates around the loop, and on the hash state h[]. I don't believe + * that the clamps on h4 and on h0 are strictly necessary, but it's close + * (for h4 anyway), and better safe than sorry. + * + * The clamps on h[] are necessary for the output to be correct even in the + * common case and for short inputs. + */ + for (i = 0; i < 64; i++) { + // load up the input word for this round + if (i < 16) { + tmp = w[i]; + } else { + a = w[(i + 1) & 15]; + b = w[(i + 14) & 15]; + tmp = w[i & 15] = + (((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) + + ((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) + + w[i & 15] + + w[(i + 9) & 15]) | + 0; + } - function frac(x) { return (x-Math.floor(x)) * 0x100000000 | 0; } + tmp = + tmp + + h7 + + ((h4 >>> 6) ^ (h4 >>> 11) ^ (h4 >>> 25) ^ (h4 << 26) ^ (h4 << 21) ^ (h4 << 7)) + + (h6 ^ (h4 & (h5 ^ h6))) + + k[i]; // | 0; + + // shift register + h7 = h6; + h6 = h5; + h5 = h4; + h4 = (h3 + tmp) | 0; + h3 = h2; + h2 = h1; + h1 = h0; + + h0 = + (tmp + + ((h1 & h2) ^ (h3 & (h1 ^ h2))) + + ((h1 >>> 2) ^ (h1 >>> 13) ^ (h1 >>> 22) ^ (h1 << 30) ^ (h1 << 19) ^ (h1 << 10))) | + 0; + } + + h[0] = (h[0] + h0) | 0; + h[1] = (h[1] + h1) | 0; + h[2] = (h[2] + h2) | 0; + h[3] = (h[3] + h3) | 0; + h[4] = (h[4] + h4) | 0; + h[5] = (h[5] + h5) | 0; + h[6] = (h[6] + h6) | 0; + h[7] = (h[7] + h7) | 0; + }, + }; - for (; i<64; prime++) { - isPrime = true; - for (factor=2; factor*factor <= prime; factor++) { - if (prime % factor === 0) { - isPrime = false; - break; - } - } - if (isPrime) { - if (i<8) { - this._init[i] = frac(Math.pow(prime, 1/2)); - } - this._key[i] = frac(Math.pow(prime, 1/3)); - i++; - } - } - }, - - /** - * Perform one cycle of SHA-256. - * @param {Uint32Array|bitArray} w one block of words. - * @private - */ - _block:function (w) { - var i, tmp, a, b, - h = this._h, - k = this._key, - h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], - h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7]; - - /* Rationale for placement of |0 : - * If a value can overflow is original 32 bits by a factor of more than a few - * million (2^23 ish), there is a possibility that it might overflow the - * 53-bit mantissa and lose precision. + /** @fileOverview HMAC implementation. * - * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that - * propagates around the loop, and on the hash state h[]. I don't believe - * that the clamps on h4 and on h0 are strictly necessary, but it's close - * (for h4 anyway), and better safe than sorry. - * - * The clamps on h[] are necessary for the output to be correct even in the - * common case and for short inputs. - */ - for (i=0; i<64; i++) { - // load up the input word for this round - if (i<16) { - tmp = w[i]; - } else { - a = w[(i+1 ) & 15]; - b = w[(i+14) & 15]; - tmp = w[i&15] = ((a>>>7 ^ a>>>18 ^ a>>>3 ^ a<<25 ^ a<<14) + - (b>>>17 ^ b>>>19 ^ b>>>10 ^ b<<15 ^ b<<13) + - w[i&15] + w[(i+9) & 15]) | 0; - } - - tmp = (tmp + h7 + (h4>>>6 ^ h4>>>11 ^ h4>>>25 ^ h4<<26 ^ h4<<21 ^ h4<<7) + (h6 ^ h4&(h5^h6)) + k[i]); // | 0; - - // shift register - h7 = h6; h6 = h5; h5 = h4; - h4 = h3 + tmp | 0; - h3 = h2; h2 = h1; h1 = h0; - - h0 = (tmp + ((h1&h2) ^ (h3&(h1^h2))) + (h1>>>2 ^ h1>>>13 ^ h1>>>22 ^ h1<<30 ^ h1<<19 ^ h1<<10)) | 0; - } - - h[0] = h[0]+h0 | 0; - h[1] = h[1]+h1 | 0; - h[2] = h[2]+h2 | 0; - h[3] = h[3]+h3 | 0; - h[4] = h[4]+h4 | 0; - h[5] = h[5]+h5 | 0; - h[6] = h[6]+h6 | 0; - h[7] = h[7]+h7 | 0; - } - }; - + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ - /** @fileOverview HMAC implementation. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + /** HMAC with the specified hash function. + * @constructor + * @param {bitArray} key the key for HMAC. + * @param {Object} [Hash=sjcl.hash.sha256] The hash function to use. + */ + sjcl.misc.hmac = function (key, Hash) { + this._hash = Hash = Hash || sjcl.hash.sha256; + var exKey = [[], []], + i, + bs = Hash.prototype.blockSize / 32; + this._baseHash = [new Hash(), new Hash()]; - /** HMAC with the specified hash function. - * @constructor - * @param {bitArray} key the key for HMAC. - * @param {Object} [Hash=sjcl.hash.sha256] The hash function to use. - */ - sjcl.misc.hmac = function (key, Hash) { - this._hash = Hash = Hash || sjcl.hash.sha256; - var exKey = [[],[]], i, - bs = Hash.prototype.blockSize / 32; - this._baseHash = [new Hash(), new Hash()]; + if (key.length > bs) { + key = Hash.hash(key); + } - if (key.length > bs) { - key = Hash.hash(key); - } - - for (i=0; i any } */ - function toStringGetTrap (targetFn, mockValue) { + function toStringGetTrap(targetFn, mockValue) { // We wrap two levels deep to handle toString.toString() calls - return function get (target, prop, receiver) { + return function get(target, prop, receiver) { if (prop === 'toString') { const origToString = Reflect.get(targetFn, 'toString', targetFn); const toStringProxy = new Proxy(origToString, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { // only mock toString() when called on the proxy itself. If the method is applied to some other object, it should behave as a normal toString() if (thisArg === receiver) { if (mockValue) { - return mockValue + return mockValue; } - return Reflect.apply(target, targetFn, argumentsList) + return Reflect.apply(target, targetFn, argumentsList); } else { - return Reflect.apply(target, thisArg, argumentsList) + return Reflect.apply(target, thisArg, argumentsList); } }, - get (target, prop, receiver) { + get(target, prop, receiver) { // handle toString.toString() result if (prop === 'toString') { const origToStringToString = Reflect.get(origToString, 'toString', origToString); const toStringToStringProxy = new Proxy(origToStringToString, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { if (thisArg === toStringProxy) { - return Reflect.apply(target, origToString, argumentsList) + return Reflect.apply(target, origToString, argumentsList); } else { - return Reflect.apply(target, thisArg, argumentsList) + return Reflect.apply(target, thisArg, argumentsList); } - } + }, }); - return toStringToStringProxy + return toStringToStringProxy; } - return Reflect.get(target, prop, receiver) - } + return Reflect.get(target, prop, receiver); + }, }); - return toStringProxy + return toStringProxy; } - return Reflect.get(target, prop, receiver) - } + return Reflect.get(target, prop, receiver); + }; } /** @@ -1988,9 +2036,9 @@ * @param {typeof Object.defineProperty} definePropertyFn - function to use for defining the property * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - function wrapProperty (object, propertyName, descriptor, definePropertyFn) { + function wrapProperty(object, propertyName, descriptor, definePropertyFn) { if (!object) { - return + return; } /** @type {StrictPropertyDescriptor} */ @@ -1998,21 +2046,22 @@ const origDescriptor = getOwnPropertyDescriptor(object, propertyName); if (!origDescriptor) { // this happens if the property is not implemented in the browser - return + return; } - if (('value' in origDescriptor && 'value' in descriptor) || + if ( + ('value' in origDescriptor && 'value' in descriptor) || ('get' in origDescriptor && 'get' in descriptor) || ('set' in origDescriptor && 'set' in descriptor) ) { definePropertyFn(object, propertyName, { ...origDescriptor, - ...descriptor + ...descriptor, }); - return origDescriptor + return origDescriptor; } else { // if the property is defined with get/set it must be wrapped with a get/set. If it's defined with a `value`, it must be wrapped with a `value` - throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`) + throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`); } } @@ -2024,9 +2073,9 @@ * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - function wrapMethod (object, propertyName, wrapperFn, definePropertyFn) { + function wrapMethod(object, propertyName, wrapperFn, definePropertyFn) { if (!object) { - return + return; } /** @type {StrictPropertyDescriptor} */ @@ -2034,25 +2083,25 @@ const origDescriptor = getOwnPropertyDescriptor(object, propertyName); if (!origDescriptor) { // this happens if the property is not implemented in the browser - return + return; } // @ts-expect-error - we check for undefined below const origFn = origDescriptor.value; if (!origFn || typeof origFn !== 'function') { // method properties are expected to be defined with a `value` - throw new Error(`Property ${propertyName} does not look like a method`) + throw new Error(`Property ${propertyName} does not look like a method`); } const newFn = wrapToString(function () { - return wrapperFn.call(this, origFn, ...arguments) + return wrapperFn.call(this, origFn, ...arguments); }, origFn); definePropertyFn(object, propertyName, { ...origDescriptor, - value: newFn + value: newFn, }); - return origDescriptor + return origDescriptor; } /** @@ -2062,25 +2111,20 @@ * @param {DefineInterfaceOptions} options - options for defining the interface * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property */ - function shimInterface ( - interfaceName, - ImplClass, - options, - definePropertyFn - ) { + function shimInterface(interfaceName, ImplClass, options, definePropertyFn) { /** @type {DefineInterfaceOptions} */ const defaultOptions = { allowConstructorCall: false, disallowConstructor: false, constructorErrorMessage: 'Illegal constructor', - wrapToString: true + wrapToString: true, }; const fullOptions = { interfaceDescriptorOptions: { writable: true, enumerable: false, configurable: true, value: ImplClass }, ...defaultOptions, - ...options + ...options, }; // In some cases we can get away without a full proxy, but in many cases below we need it. @@ -2094,14 +2138,14 @@ if (fullOptions.allowConstructorCall) { // make the constructor function callable without new proxyHandler.apply = function (target, thisArg, argumentsList) { - return Reflect.construct(target, argumentsList, target) + return Reflect.construct(target, argumentsList, target); }; } // make the constructor function throw when called without new if (fullOptions.disallowConstructor) { proxyHandler.construct = function () { - throw new TypeError(fullOptions.constructorErrorMessage) + throw new TypeError(fullOptions.constructorErrorMessage); }; } @@ -2110,14 +2154,14 @@ for (const [prop, descriptor] of objectEntries(getOwnPropertyDescriptors(ImplClass.prototype))) { if (prop !== 'constructor' && descriptor.writable && typeof descriptor.value === 'function') { ImplClass.prototype[prop] = new Proxy(descriptor.value, { - get: toStringGetTrap(descriptor.value, `function ${prop}() { [native code] }`) + get: toStringGetTrap(descriptor.value, `function ${prop}() { [native code] }`), }); } } // wrap toString on the constructor function itself Object.assign(proxyHandler, { - get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`) + get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`), }); } @@ -2141,15 +2185,11 @@ value: interfaceName, configurable: true, enumerable: false, - writable: false + writable: false, }); // interfaces are exposed directly on the global object, not on its prototype - definePropertyFn( - globalThis, - interfaceName, - { ...fullOptions.interfaceDescriptorOptions, value: Interface } - ); + definePropertyFn(globalThis, interfaceName, { ...fullOptions.interfaceDescriptorOptions, value: Interface }); } /** @@ -2164,13 +2204,13 @@ * @param {boolean} readOnly - whether the property should be read-only * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property */ - function shimProperty (baseObject, propertyName, implInstance, readOnly, definePropertyFn) { + function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn) { // @ts-expect-error - implInstance is a class instance const ImplClass = implInstance.constructor; // mask toString() and toString.toString() on the instance const proxiedInstance = new Proxy(implInstance, { - get: toStringGetTrap(implInstance, `[object ${ImplClass.name}]`) + get: toStringGetTrap(implInstance, `[object ${ImplClass.name}]`), }); /** @type {StrictPropertyDescriptor} */ @@ -2180,21 +2220,23 @@ // But there could be other cases, e.g. a property with both a getter and a setter. These could be defined with a raw defineProperty() call. // Important: make sure to cover each new shim with a test that verifies that all descriptors match the standard API. if (readOnly) { - const getter = function get () { return proxiedInstance }; + const getter = function get() { + return proxiedInstance; + }; const proxiedGetter = new Proxy(getter, { - get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`) + get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`), }); descriptor = { configurable: true, enumerable: true, - get: proxiedGetter + get: proxiedGetter, }; } else { descriptor = { configurable: true, enumerable: true, writable: true, - value: proxiedInstance + value: proxiedInstance, }; } @@ -2268,7 +2310,7 @@ * @param {import('../index.js').MessagingContext} messagingContext * @internal */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; this.globals = { @@ -2277,11 +2319,11 @@ JSONstringify: window.JSON.stringify, Promise: window.Promise, Error: window.Error, - String: window.String + String: window.String, }; for (const [methodName, fn] of Object.entries(this.config.methods)) { if (typeof fn !== 'function') { - throw new Error('cannot create WindowsMessagingTransport, missing the method: ' + methodName) + throw new Error('cannot create WindowsMessagingTransport, missing the method: ' + methodName); } } } @@ -2289,7 +2331,7 @@ /** * @param {import('../index.js').NotificationMessage} msg */ - notify (msg) { + notify(msg) { const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); const notification = WindowsNotification.fromNotification(msg, data); this.config.methods.postMessage(notification); @@ -2300,7 +2342,7 @@ * @param {{signal?: AbortSignal}} opts * @return {Promise} */ - request (msg, opts = {}) { + request(msg, opts = {}) { // convert the message to window-specific naming const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); const outgoing = WindowsRequestMessage.fromRequest(msg, data); @@ -2310,19 +2352,17 @@ // compare incoming messages against the `msg.id` const comparator = (eventData) => { - return eventData.featureName === msg.featureName && - eventData.context === msg.context && - eventData.id === msg.id + return eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.id === msg.id; }; /** * @param data * @return {data is import('../index.js').MessageResponse} */ - function isMessageResponse (data) { - if ('result' in data) return true - if ('error' in data) return true - return false + function isMessageResponse(data) { + if ('result' in data) return true; + if ('error' in data) return true; + return false; } // now wait for a matching message @@ -2333,11 +2373,11 @@ if (!isMessageResponse(value)) { console.warn('unknown response type', value); - return reject(new this.globals.Error('unknown response')) + return reject(new this.globals.Error('unknown response')); } if (value.result) { - return resolve(value.result) + return resolve(value.result); } const message = this.globals.String(value.error?.message || 'unknown error'); @@ -2346,28 +2386,30 @@ } catch (e) { reject(e); } - }) + }); } /** * @param {import('../index.js').Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { // compare incoming messages against the `msg.subscriptionName` const comparator = (eventData) => { - return eventData.featureName === msg.featureName && + return ( + eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.subscriptionName === msg.subscriptionName + ); }; // only forward the 'params' from a SubscriptionEvent const cb = (eventData) => { - return callback(eventData.params) + return callback(eventData.params); }; // now listen for matching incoming messages. - return this._subscribe(comparator, {}, cb) + return this._subscribe(comparator, {}, cb); } /** @@ -2379,10 +2421,10 @@ * @param {(value: Incoming, unsubscribe: (()=>void)) => void} callback * @internal */ - _subscribe (comparator, options, callback) { + _subscribe(comparator, options, callback) { // if already aborted, reject immediately if (options?.signal?.aborted) { - throw new DOMException('Aborted', 'AbortError') + throw new DOMException('Aborted', 'AbortError'); } /** @type {(()=>void) | undefined} */ // eslint-disable-next-line prefer-const @@ -2395,15 +2437,15 @@ if (this.messagingContext.env === 'production') { if (event.origin !== null && event.origin !== undefined) { console.warn('ignoring because evt.origin is not `null` or `undefined`'); - return + return; } } if (!event.data) { console.warn('data absent from message'); - return + return; } if (comparator(event.data)) { - if (!teardown) throw new Error('unreachable') + if (!teardown) throw new Error('unreachable'); callback(event.data, teardown); } }; @@ -2411,24 +2453,24 @@ // what to do if this promise is aborted const abortHandler = () => { teardown?.(); - throw new DOMException('Aborted', 'AbortError') + throw new DOMException('Aborted', 'AbortError'); }; // console.log('DEBUG: handler setup', { config, comparator }) - + this.config.methods.addEventListener('message', idHandler); options?.signal?.addEventListener('abort', abortHandler); teardown = () => { // console.log('DEBUG: handler teardown', { config, comparator }) - + this.config.methods.removeEventListener('message', idHandler); options?.signal?.removeEventListener('abort', abortHandler); }; return () => { teardown?.(); - } + }; } } @@ -2457,7 +2499,7 @@ * @param {WindowsInteropMethods} params.methods * @internal */ - constructor (params) { + constructor(params) { /** * The methods required for communication */ @@ -2484,7 +2526,7 @@ * @param {Record} [params.Data] * @internal */ - constructor (params) { + constructor(params) { /** * Alias for: {@link NotificationMessage.context} */ @@ -2508,15 +2550,15 @@ * @param {NotificationMessage} notification * @returns {WindowsNotification} */ - static fromNotification (notification, data) { + static fromNotification(notification, data) { /** @type {WindowsNotification} */ const output = { Data: data, Feature: notification.context, SubFeatureName: notification.featureName, - Name: notification.method + Name: notification.method, }; - return output + return output; } } @@ -2535,7 +2577,7 @@ * @param {string} [params.Id] * @internal */ - constructor (params) { + constructor(params) { this.Feature = params.Feature; this.SubFeatureName = params.SubFeatureName; this.Name = params.Name; @@ -2549,16 +2591,16 @@ * @param {Record} data * @returns {WindowsRequestMessage} */ - static fromRequest (msg, data) { + static fromRequest(msg, data) { /** @type {WindowsRequestMessage} */ const output = { Data: data, Feature: msg.context, SubFeatureName: msg.featureName, Name: msg.method, - Id: msg.id + Id: msg.id, }; - return output + return output; } } @@ -2594,7 +2636,7 @@ * @param {Record} [params.params] * @internal */ - constructor (params) { + constructor(params) { /** * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` * @type {string} @@ -2633,7 +2675,7 @@ * @param {Record} [params.params] * @internal */ - constructor (params) { + constructor(params) { /** * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` */ @@ -2661,7 +2703,7 @@ * @param {string} params.subscriptionName * @internal */ - constructor (params) { + constructor(params) { this.context = params.context; this.featureName = params.featureName; this.subscriptionName = params.subscriptionName; @@ -2673,18 +2715,16 @@ * @param {Record} data * @return {data is MessageResponse} */ - function isResponseFor (request, data) { + function isResponseFor(request, data) { if ('result' in data) { - return data.featureName === request.featureName && - data.context === request.context && - data.id === request.id + return data.featureName === request.featureName && data.context === request.context && data.id === request.id; } if ('error' in data) { if ('message' in data.error) { - return true + return true; } } - return false + return false; } /** @@ -2692,14 +2732,12 @@ * @param {Record} data * @return {data is SubscriptionEvent} */ - function isSubscriptionEventFor (sub, data) { + function isSubscriptionEventFor(sub, data) { if ('subscriptionName' in data) { - return data.featureName === sub.featureName && - data.context === sub.context && - data.subscriptionName === sub.subscriptionName + return data.featureName === sub.featureName && data.context === sub.context && data.subscriptionName === sub.subscriptionName; } - return false + return false; } /** @@ -2763,7 +2801,7 @@ * @param {WebkitMessagingConfig} config * @param {import('../index.js').MessagingContext} messagingContext */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; this.globals = captureGlobals(); @@ -2778,25 +2816,25 @@ * @param {*} data * @internal */ - wkSend (handler, data = {}) { + wkSend(handler, data = {}) { if (!(handler in this.globals.window.webkit.messageHandlers)) { - throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler) + throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler); } if (!this.config.hasModernWebkitAPI) { const outgoing = { ...data, messageHandling: { ...data.messageHandling, - secret: this.config.secret - } + secret: this.config.secret, + }, }; if (!(handler in this.globals.capturedWebkitHandlers)) { - throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler) + throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler); } else { - return this.globals.capturedWebkitHandlers[handler](outgoing) + return this.globals.capturedWebkitHandlers[handler](outgoing); } } - return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data) + return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data); } /** @@ -2806,10 +2844,10 @@ * @returns {Promise<*>} * @internal */ - async wkSendAndWait (handler, data) { + async wkSendAndWait(handler, data) { if (this.config.hasModernWebkitAPI) { const response = await this.wkSend(handler, data); - return this.globals.JSONparse(response || '{}') + return this.globals.JSONparse(response || '{}'); } try { @@ -2817,10 +2855,7 @@ const key = await this.createRandKey(); const iv = this.createRandIv(); - const { - ciphertext, - tag - } = await new this.globals.Promise((/** @type {any} */ resolve) => { + const { ciphertext, tag } = await new this.globals.Promise((/** @type {any} */ resolve) => { this.generateRandomMethod(randMethodName, resolve); // @ts-expect-error - this is a carve-out for catalina that will be removed soon @@ -2828,22 +2863,22 @@ methodName: randMethodName, secret: this.config.secret, key: this.globals.Arrayfrom(key), - iv: this.globals.Arrayfrom(iv) + iv: this.globals.Arrayfrom(iv), }); this.wkSend(handler, data); }); const cipher = new this.globals.Uint8Array([...ciphertext, ...tag]); const decrypted = await this.decrypt(cipher, key, iv); - return this.globals.JSONparse(decrypted || '{}') + return this.globals.JSONparse(decrypted || '{}'); } catch (e) { // re-throw when the error is just a 'MissingHandler' if (e instanceof MissingHandler) { - throw e + throw e; } else { console.error('decryption failed', e); console.error(e); - return { error: e } + return { error: e }; } } } @@ -2851,27 +2886,27 @@ /** * @param {import('../index.js').NotificationMessage} msg */ - notify (msg) { + notify(msg) { this.wkSend(msg.context, msg); } /** * @param {import('../index.js').RequestMessage} msg */ - async request (msg) { + async request(msg) { const data = await this.wkSendAndWait(msg.context, msg); if (isResponseFor(msg, data)) { if (data.result) { - return data.result || {} + return data.result || {}; } // forward the error if one was given explicity if (data.error) { - throw new Error(data.error.message) + throw new Error(data.error.message); } } - throw new Error('an unknown error occurred') + throw new Error('an unknown error occurred'); } /** @@ -2881,7 +2916,7 @@ * @param {Function} callback * @internal */ - generateRandomMethod (randomMethodName, callback) { + generateRandomMethod(randomMethodName, callback) { this.globals.ObjectDefineProperty(this.globals.window, randomMethodName, { enumerable: false, // configurable, To allow for deletion later @@ -2891,10 +2926,9 @@ * @param {any[]} args */ value: (...args) => { - callback(...args); delete this.globals.window[randomMethodName]; - } + }, }); } @@ -2902,16 +2936,16 @@ * @internal * @return {string} */ - randomString () { - return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0] + randomString() { + return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0]; } /** * @internal * @return {string} */ - createRandMethodName () { - return '_' + this.randomString() + createRandMethodName() { + return '_' + this.randomString(); } /** @@ -2920,25 +2954,25 @@ */ algoObj = { name: 'AES-GCM', - length: 256 - } + length: 256, + }; /** * @returns {Promise} * @internal */ - async createRandKey () { + async createRandKey() { const key = await this.globals.generateKey(this.algoObj, true, ['encrypt', 'decrypt']); const exportedKey = await this.globals.exportKey('raw', key); - return new this.globals.Uint8Array(exportedKey) + return new this.globals.Uint8Array(exportedKey); } /** * @returns {Uint8Array} * @internal */ - createRandIv () { - return this.globals.getRandomValues(new this.globals.Uint8Array(12)) + createRandIv() { + return this.globals.getRandomValues(new this.globals.Uint8Array(12)); } /** @@ -2948,17 +2982,17 @@ * @returns {Promise} * @internal */ - async decrypt (ciphertext, key, iv) { + async decrypt(ciphertext, key, iv) { const cryptoKey = await this.globals.importKey('raw', key, 'AES-GCM', false, ['decrypt']); const algo = { name: 'AES-GCM', - iv + iv, }; const decrypted = await this.globals.decrypt(algo, cryptoKey, ciphertext); const dec = new this.globals.TextDecoder(); - return dec.decode(decrypted) + return dec.decode(decrypted); } /** @@ -2967,9 +3001,9 @@ * * @param {string[]} handlerNames */ - captureWebkitHandlers (handlerNames) { + captureWebkitHandlers(handlerNames) { const handlers = window.webkit.messageHandlers; - if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all') + if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all'); for (const webkitMessageHandlerName of handlerNames) { if (typeof handlers[webkitMessageHandlerName]?.postMessage === 'function') { /** @@ -2988,10 +3022,10 @@ * @param {import('../index.js').Subscription} msg * @param {(value: unknown) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { // for now, bail if there's already a handler setup for this subscription if (msg.subscriptionName in this.globals.window) { - throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`) + throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`); } this.globals.ObjectDefineProperty(this.globals.window, msg.subscriptionName, { enumerable: false, @@ -3003,11 +3037,11 @@ } else { console.warn('Received a message that did not match the subscription', data); } - } + }, }); return () => { this.globals.ReflectDeleteProperty(this.globals.window, msg.subscriptionName); - } + }; } } @@ -3028,7 +3062,7 @@ * @param {string} params.secret * @internal */ - constructor (params) { + constructor(params) { /** * Whether or not the current WebKit Platform supports secure messaging * by default (eg: macOS 11+) @@ -3070,7 +3104,7 @@ * @param {number[]} params.key * @param {number[]} params.iv */ - constructor (params) { + constructor(params) { /** * The method that's been appended to `window` to be called later */ @@ -3094,7 +3128,7 @@ * Capture some globals used for messaging handling to prevent page * scripts from tampering with this */ - function captureGlobals () { + function captureGlobals() { // Create base with null prototype const globals = { window, @@ -3113,7 +3147,7 @@ ObjectDefineProperty: window.Object.defineProperty, addEventListener: window.addEventListener.bind(window), /** @type {Record} */ - capturedWebkitHandlers: {} + capturedWebkitHandlers: {}, }; if (isSecureContext) { // skip for HTTP content since window.crypto.subtle is unavailable @@ -3123,7 +3157,7 @@ globals.encrypt = window.crypto.subtle.encrypt.bind(window.crypto.subtle); globals.decrypt = window.crypto.subtle.decrypt.bind(window.crypto.subtle); } - return globals + return globals; } /** @@ -3155,7 +3189,7 @@ * @param {MessagingContext} messagingContext * @internal */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; } @@ -3163,7 +3197,7 @@ /** * @param {NotificationMessage} msg */ - notify (msg) { + notify(msg) { try { this.config.sendMessageThrows?.(JSON.stringify(msg)); } catch (e) { @@ -3175,7 +3209,7 @@ * @param {RequestMessage} msg * @return {Promise} */ - request (msg) { + request(msg) { return new Promise((resolve, reject) => { // subscribe early const unsub = this.config.subscribe(msg.id, handler); @@ -3187,33 +3221,33 @@ reject(new Error('request failed to send: ' + e.message || 'unknown error')); } - function handler (data) { + function handler(data) { if (isResponseFor(msg, data)) { // success case, forward .result only if (data.result) { resolve(data.result || {}); - return unsub() + return unsub(); } // error case, forward the error as a regular promise rejection if (data.error) { reject(new Error(data.error.message)); - return unsub() + return unsub(); } // getting here is undefined behavior unsub(); - throw new Error('unreachable: must have `result` or `error` key by this point') + throw new Error('unreachable: must have `result` or `error` key by this point'); } } - }) + }); } /** * @param {Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { const unsub = this.config.subscribe(msg.subscriptionName, (data) => { if (isSubscriptionEventFor(msg, data)) { callback(data.params || {}); @@ -3221,7 +3255,7 @@ }); return () => { unsub(); - } + }; } } @@ -3300,7 +3334,7 @@ */ class AndroidMessagingConfig { /** @type {(json: string, secret: string) => void} */ - _capturedHandler + _capturedHandler; /** * @param {object} params * @param {Record} params.target @@ -3312,7 +3346,7 @@ * @param {string} params.messageCallback - the name of the callback that the native * side will use to send messages back to the javascript side */ - constructor (params) { + constructor(params) { this.target = params.target; this.debug = params.debug; this.javascriptInterface = params.javascriptInterface; @@ -3346,7 +3380,7 @@ * @throws * @internal */ - sendMessageThrows (json) { + sendMessageThrows(json) { this._capturedHandler(json, this.messageSecret); } @@ -3363,11 +3397,11 @@ * @returns {() => void} * @internal */ - subscribe (id, callback) { + subscribe(id, callback) { this.listeners.set(id, callback); return () => { this.listeners.delete(id); - } + }; } /** @@ -3379,10 +3413,10 @@ * @param {MessageResponse | SubscriptionEvent} payload * @internal */ - _dispatch (payload) { + _dispatch(payload) { // do nothing if the response is empty // this prevents the next `in` checks from throwing in test/debug scenarios - if (!payload) return this._log('no response') + if (!payload) return this._log('no response'); // if the payload has an 'id' field, then it's a message response if ('id' in payload) { @@ -3408,9 +3442,9 @@ * @param {(...args: any[]) => any} fn * @param {string} [context] */ - _tryCatch (fn, context = 'none') { + _tryCatch(fn, context = 'none') { try { - return fn() + return fn(); } catch (e) { if (this.debug) { console.error('AndroidMessagingConfig error:', context); @@ -3422,7 +3456,7 @@ /** * @param {...any} args */ - _log (...args) { + _log(...args) { if (this.debug) { console.log('AndroidMessagingConfig', ...args); } @@ -3431,7 +3465,7 @@ /** * Capture the global handler and remove it from the global object. */ - _captureGlobalHandler () { + _captureGlobalHandler() { const { target, javascriptInterface } = this; if (Object.prototype.hasOwnProperty.call(target, javascriptInterface)) { @@ -3448,7 +3482,7 @@ * Assign the incoming handler method to the global object. * This is the method that Android will call to deliver messages. */ - _assignHandlerMethod () { + _assignHandlerMethod() { /** * @type {(secret: string, response: MessageResponse | SubscriptionEvent) => void} */ @@ -3459,7 +3493,7 @@ }; Object.defineProperty(this.target, this.messageCallback, { - value: responseHandler + value: responseHandler, }); } } @@ -3498,7 +3532,7 @@ * @param {"production" | "development"} params.env * @internal */ - constructor (params) { + constructor(params) { this.context = params.context; this.featureName = params.featureName; this.env = params.env; @@ -3517,7 +3551,7 @@ * @param {MessagingContext} messagingContext * @param {MessagingConfig} config */ - constructor (messagingContext, config) { + constructor(messagingContext, config) { this.messagingContext = messagingContext; this.transport = getTransport(config, this.messagingContext); } @@ -3535,12 +3569,12 @@ * @param {string} name * @param {Record} [data] */ - notify (name, data = {}) { + notify(name, data = {}) { const message = new NotificationMessage({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, method: name, - params: data + params: data, }); this.transport.notify(message); } @@ -3559,16 +3593,16 @@ * @param {Record} [data] * @return {Promise} */ - request (name, data = {}) { + request(name, data = {}) { const id = globalThis?.crypto?.randomUUID?.() || name + '.response'; const message = new RequestMessage({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, method: name, params: data, - id + id, }); - return this.transport.request(message) + return this.transport.request(message); } /** @@ -3576,13 +3610,13 @@ * @param {(value: unknown) => void} callback * @return {() => void} */ - subscribe (name, callback) { + subscribe(name, callback) { const msg = new Subscription({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, - subscriptionName: name + subscriptionName: name, }); - return this.transport.subscribe(msg, callback) + return this.transport.subscribe(msg, callback); } } @@ -3596,7 +3630,7 @@ /** * @param {MessagingTransport} impl */ - constructor (impl) { + constructor(impl) { this.impl = impl; } } @@ -3609,21 +3643,21 @@ * @param {TestTransportConfig} config * @param {MessagingContext} messagingContext */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.config = config; this.messagingContext = messagingContext; } - notify (msg) { - return this.config.impl.notify(msg) + notify(msg) { + return this.config.impl.notify(msg); } - request (msg) { - return this.config.impl.request(msg) + request(msg) { + return this.config.impl.request(msg); } - subscribe (msg, callback) { - return this.config.impl.subscribe(msg, callback) + subscribe(msg, callback) { + return this.config.impl.subscribe(msg, callback); } } @@ -3632,20 +3666,20 @@ * @param {MessagingContext} messagingContext * @returns {MessagingTransport} */ - function getTransport (config, messagingContext) { + function getTransport(config, messagingContext) { if (config instanceof WebkitMessagingConfig) { - return new WebkitMessagingTransport(config, messagingContext) + return new WebkitMessagingTransport(config, messagingContext); } if (config instanceof WindowsMessagingConfig) { - return new WindowsMessagingTransport(config, messagingContext) + return new WindowsMessagingTransport(config, messagingContext); } if (config instanceof AndroidMessagingConfig) { - return new AndroidMessagingTransport(config, messagingContext) + return new AndroidMessagingTransport(config, messagingContext); } if (config instanceof TestTransportConfig) { - return new TestTransport(config, messagingContext) + return new TestTransport(config, messagingContext); } - throw new Error('unreachable') + throw new Error('unreachable'); } /** @@ -3656,7 +3690,7 @@ * @param {string} message * @param {string} handlerName */ - constructor (message, handlerName) { + constructor(message, handlerName) { super(message); this.handlerName = handlerName; } @@ -3670,9 +3704,9 @@ /** * @deprecated - A temporary constructor for the extension to make the messaging config */ - function extensionConstructMessagingConfig () { + function extensionConstructMessagingConfig() { const messagingTransport = new SendMessageMessagingTransport(); - return new TestTransportConfig(messagingTransport) + return new TestTransportConfig(messagingTransport); } /** @@ -3688,9 +3722,9 @@ * Queue of callbacks to be called with messages sent from the Platform. * This is used to connect requests with responses and to trigger subscriptions callbacks. */ - _queue = new Set() + _queue = new Set(); - constructor () { + constructor() { this.globals = { window: globalThis, globalThis, @@ -3698,7 +3732,7 @@ JSONstringify: globalThis.JSON.stringify, Promise: globalThis.Promise, Error: globalThis.Error, - String: globalThis.String + String: globalThis.String, }; } @@ -3707,14 +3741,14 @@ * with callback functions in the _queue. * @param {any} response */ - onResponse (response) { + onResponse(response) { this._queue.forEach((subscription) => subscription(response)); } /** * @param {import('@duckduckgo/messaging').NotificationMessage} msg */ - notify (msg) { + notify(msg) { let params = msg.params; // Unwrap 'setYoutubePreviewsEnabled' params to match expected payload @@ -3735,9 +3769,9 @@ * @param {import('@duckduckgo/messaging').RequestMessage} req * @return {Promise} */ - request (req) { + request(req) { let comparator = (eventData) => { - return eventData.responseMessageType === req.method + return eventData.responseMessageType === req.method; }; let params = req.params; @@ -3750,7 +3784,7 @@ eventData.responseMessageType === req.method && eventData.response && eventData.response.videoURL === req.params?.videoURL - ) + ); }; params = req.params?.videoURL; } @@ -3761,29 +3795,26 @@ this._subscribe(comparator, (msgRes, unsubscribe) => { unsubscribe(); - return resolve(msgRes.response) + return resolve(msgRes.response); }); - }) + }); } /** * @param {import('@duckduckgo/messaging').Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { const comparator = (eventData) => { - return ( - eventData.messageType === msg.subscriptionName || - eventData.responseMessageType === msg.subscriptionName - ) + return eventData.messageType === msg.subscriptionName || eventData.responseMessageType === msg.subscriptionName; }; // only forward the 'params' ('response' in current format), to match expected // callback from a SubscriptionEvent const cb = (eventData) => { - return callback(eventData.response) + return callback(eventData.response); }; - return this._subscribe(comparator, cb) + return this._subscribe(comparator, cb); } /** @@ -3791,7 +3822,7 @@ * @param {(value: any, unsubscribe: (()=>void)) => void} callback * @internal */ - _subscribe (comparator, callback) { + _subscribe(comparator, callback) { /** @type {(()=>void) | undefined} */ // eslint-disable-next-line prefer-const let teardown; @@ -3802,10 +3833,10 @@ const idHandler = (event) => { if (!event) { console.warn('no message available'); - return + return; } if (comparator(event)) { - if (!teardown) throw new this.globals.Error('unreachable') + if (!teardown) throw new this.globals.Error('unreachable'); callback(event, teardown); } }; @@ -3817,7 +3848,7 @@ return () => { teardown?.(); - } + }; } } @@ -3837,93 +3868,93 @@ class ContentFeature { /** @type {import('./utils.js').RemoteConfig | undefined} */ - #bundledConfig + #bundledConfig; /** @type {object | undefined} */ - #trackerLookup + #trackerLookup; /** @type {boolean | undefined} */ - #documentOriginIsTracker + #documentOriginIsTracker; /** @type {Record | undefined} */ // eslint-disable-next-line no-unused-private-class-members - #bundledfeatureSettings + #bundledfeatureSettings; /** @type {import('../../messaging').Messaging} */ // eslint-disable-next-line no-unused-private-class-members - #messaging + #messaging; /** @type {boolean} */ - #isDebugFlagSet = false + #isDebugFlagSet = false; /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */ - #args + #args; - constructor (featureName) { + constructor(featureName) { this.name = featureName; this.#args = null; this.monitor = new PerformanceMonitor(); } - get isDebug () { - return this.#args?.debug || false + get isDebug() { + return this.#args?.debug || false; } - get desktopModeEnabled () { - return this.#args?.desktopModeEnabled || false + get desktopModeEnabled() { + return this.#args?.desktopModeEnabled || false; } - get forcedZoomEnabled () { - return this.#args?.forcedZoomEnabled || false + get forcedZoomEnabled() { + return this.#args?.forcedZoomEnabled || false; } /** * @param {import('./utils').Platform} platform */ - set platform (platform) { + set platform(platform) { this._platform = platform; } - get platform () { + get platform() { // @ts-expect-error - Type 'Platform | undefined' is not assignable to type 'Platform' - return this._platform + return this._platform; } /** * @type {AssetConfig | undefined} */ - get assetConfig () { - return this.#args?.assets + get assetConfig() { + return this.#args?.assets; } /** * @returns {boolean} */ - get documentOriginIsTracker () { - return !!this.#documentOriginIsTracker + get documentOriginIsTracker() { + return !!this.#documentOriginIsTracker; } /** * @returns {object} **/ - get trackerLookup () { - return this.#trackerLookup || {} + get trackerLookup() { + return this.#trackerLookup || {}; } /** * @returns {import('./utils.js').RemoteConfig | undefined} **/ - get bundledConfig () { - return this.#bundledConfig + get bundledConfig() { + return this.#bundledConfig; } /** * @deprecated as we should make this internal to the class and not used externally * @return {MessagingContext} */ - _createMessagingContext () { + _createMessagingContext() { const contextName = 'contentScopeScripts'; return new MessagingContext({ context: contextName, env: this.isDebug ? 'development' : 'production', - featureName: this.name - }) + featureName: this.name, + }); } /** @@ -3931,16 +3962,16 @@ * * @return {import('@duckduckgo/messaging').Messaging} */ - get messaging () { - if (this._messaging) return this._messaging + get messaging() { + if (this._messaging) return this._messaging; const messagingContext = this._createMessagingContext(); let messagingConfig = this.#args?.messagingConfig; if (!messagingConfig) { - if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in') + if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in'); messagingConfig = extensionConstructMessagingConfig(); } this._messaging = new Messaging(messagingContext, messagingConfig); - return this._messaging + return this._messaging; } /** @@ -3952,9 +3983,9 @@ * @param {any} defaultValue - The default value to use if the config setting is not set * @returns The value of the config setting or the default value */ - getFeatureAttr (attrName, defaultValue) { + getFeatureAttr(attrName, defaultValue) { const configSetting = this.getFeatureSetting(attrName); - return processAttr(configSetting, defaultValue) + return processAttr(configSetting, defaultValue); } /** @@ -3963,17 +3994,17 @@ * @param {string} [featureName] * @returns {any} */ - getFeatureSetting (featureKeyName, featureName) { + getFeatureSetting(featureKeyName, featureName) { let result = this._getFeatureSettings(featureName); if (featureKeyName === 'domains') { - throw new Error('domains is a reserved feature setting key name') + throw new Error('domains is a reserved feature setting key name'); } const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => { - return a.domain.length - b.domain.length + return a.domain.length - b.domain.length; }); for (const match of domainMatch) { if (match.patchSettings === undefined) { - continue + continue; } try { result = immutableJSONPatch(result, match.patchSettings); @@ -3981,7 +4012,7 @@ console.error('Error applying patch settings', e); } } - return result?.[featureKeyName] + return result?.[featureKeyName]; } /** @@ -3989,9 +4020,9 @@ * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature * @returns {any} */ - _getFeatureSettings (featureName) { + _getFeatureSettings(featureName) { const camelFeatureName = featureName || camelcase(this.name); - return this.#args?.featureSettings?.[camelFeatureName] + return this.#args?.featureSettings?.[camelFeatureName]; } /** @@ -4001,12 +4032,12 @@ * @param {string} [featureName] * @returns {boolean} */ - getFeatureSettingEnabled (featureKeyName, featureName) { + getFeatureSettingEnabled(featureKeyName, featureName) { const result = this.getFeatureSetting(featureKeyName, featureName); if (typeof result === 'object') { - return result.state === 'enabled' + return result.state === 'enabled'; } - return result === 'enabled' + return result === 'enabled'; } /** @@ -4014,25 +4045,23 @@ * @param {string} featureKeyName * @return {any[]} */ - matchDomainFeatureSetting (featureKeyName) { + matchDomainFeatureSetting(featureKeyName) { const domain = this.#args?.site.domain; - if (!domain) return [] + if (!domain) return []; const domains = this._getFeatureSettings()?.[featureKeyName] || []; return domains.filter((rule) => { if (Array.isArray(rule.domain)) { return rule.domain.some((domainRule) => { - return matchHostname(domain, domainRule) - }) + return matchHostname(domain, domainRule); + }); } - return matchHostname(domain, rule.domain) - }) + return matchHostname(domain, rule.domain); + }); } - - init (args) { - } + init(args) {} - callInit (args) { + callInit(args) { const mark = this.monitor.mark(this.name + 'CallInit'); this.#args = args; this.platform = args.platform; @@ -4041,9 +4070,7 @@ this.measure(); } - - load (args) { - } + load(args) {} /** * This is a wrapper around `this.messaging.notify` that applies the @@ -4053,7 +4080,7 @@ * * @type {import("@duckduckgo/messaging").Messaging['notify']} */ - notify (...args) { + notify(...args) { const [name, params] = args; this.messaging.notify(name, params); } @@ -4066,9 +4093,9 @@ * * @type {import("@duckduckgo/messaging").Messaging['request']} */ - request (...args) { + request(...args) { const [name, params] = args; - return this.messaging.request(name, params) + return this.messaging.request(name, params); } /** @@ -4079,15 +4106,15 @@ * * @type {import("@duckduckgo/messaging").Messaging['subscribe']} */ - subscribe (...args) { + subscribe(...args) { const [name, cb] = args; - return this.messaging.subscribe(name, cb) + return this.messaging.subscribe(name, cb); } /** * @param {import('./content-scope-features.js').LoadArgs} args */ - callLoad (args) { + callLoad(args) { const mark = this.monitor.mark(this.name + 'CallLoad'); this.#args = args; this.platform = args.platform; @@ -4104,24 +4131,22 @@ mark.end(); } - measure () { + measure() { if (this.#args?.debug) { this.monitor.measureAll(); } } - - update () { - } + update() {} /** * Register a flag that will be added to page breakage reports */ - addDebugFlag () { - if (this.#isDebugFlagSet) return + addDebugFlag() { + if (this.#isDebugFlagSet) return; this.#isDebugFlagSet = true; this.messaging?.notify('addDebugFlag', { - flag: this.name + flag: this.name, }); } @@ -4132,7 +4157,7 @@ * @param {string} propertyName * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types */ - defineProperty (object, propertyName, descriptor) { + defineProperty(object, propertyName, descriptor) { // make sure to send a debug flag when the property is used // NOTE: properties passing data in `value` would not be caught by this ['value', 'get', 'set'].forEach((k) => { @@ -4140,16 +4165,16 @@ if (typeof descriptorProp === 'function') { const addDebugFlag = this.addDebugFlag.bind(this); const wrapper = new Proxy$1(descriptorProp, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { addDebugFlag(); - return Reflect$1.apply(descriptorProp, thisArg, argumentsList) - } + return Reflect$1.apply(descriptorProp, thisArg, argumentsList); + }, }); descriptor[k] = wrapToString(wrapper, descriptorProp); } }); - return defineProperty(object, propertyName, descriptor) + return defineProperty(object, propertyName, descriptor); } /** @@ -4159,8 +4184,8 @@ * @param {Partial} descriptor * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - wrapProperty (object, propertyName, descriptor) { - return wrapProperty(object, propertyName, descriptor, this.defineProperty.bind(this)) + wrapProperty(object, propertyName, descriptor) { + return wrapProperty(object, propertyName, descriptor, this.defineProperty.bind(this)); } /** @@ -4170,8 +4195,8 @@ * @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - wrapMethod (object, propertyName, wrapperFn) { - return wrapMethod(object, propertyName, wrapperFn, this.defineProperty.bind(this)) + wrapMethod(object, propertyName, wrapperFn) { + return wrapMethod(object, propertyName, wrapperFn, this.defineProperty.bind(this)); } /** @@ -4180,12 +4205,8 @@ * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation * @param {import('./wrapper-utils').DefineInterfaceOptions} options */ - shimInterface ( - interfaceName, - ImplClass, - options - ) { - return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)) + shimInterface(interfaceName, ImplClass, options) { + return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)); } /** @@ -4199,18 +4220,18 @@ * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) * @param {boolean} [readOnly] - whether the property should be read-only (default: false) */ - shimProperty (instanceHost, instanceProp, implInstance, readOnly = false) { - return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)) + shimProperty(instanceHost, instanceProp, implInstance, readOnly = false) { + return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)); } } class FingerprintingAudio extends ContentFeature { - init (args) { + init(args) { const { sessionKey, site } = args; const domainKey = site.domain; // In place modify array data to remove fingerprinting - function transformArrayData (channelData, domainKey, sessionKey, thisArg) { + function transformArrayData(channelData, domainKey, sessionKey, thisArg) { let { audioKey } = getCachedResponse(thisArg, args); if (!audioKey) { let cdSum = 0; @@ -4219,7 +4240,7 @@ } // If the buffer is blank, skip adding data if (cdSum === 0) { - return + return; } audioKey = getDataKeySync(sessionKey, domainKey, cdSum); setCache(thisArg, args, audioKey); @@ -4236,77 +4257,78 @@ } const copyFromChannelProxy = new DDGProxy(this, AudioBuffer.prototype, 'copyFromChannel', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const [source, channelNumber, startInChannel] = args; // This is implemented in a different way to canvas purely because calling the function copied the original value, which is not ideal - if (// If channelNumber is longer than arrayBuffer number of channels then call the default method to throw + if ( + // If channelNumber is longer than arrayBuffer number of channels then call the default method to throw // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' channelNumber > thisArg.numberOfChannels || // If startInChannel is longer than the arrayBuffer length then call the default method to throw // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' - startInChannel > thisArg.length) { + startInChannel > thisArg.length + ) { // The normal return value - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' // Call the protected getChannelData we implement, slice from the startInChannel value and assign to the source array - thisArg.getChannelData(channelNumber).slice(startInChannel).forEach((val, index) => { - source[index] = val; - }); + thisArg + .getChannelData(channelNumber) + .slice(startInChannel) + .forEach((val, index) => { + source[index] = val; + }); } catch { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } - } + }, }); copyFromChannelProxy.overload(); const cacheExpiry = 60; const cacheData = new WeakMap(); - function getCachedResponse (thisArg, args) { + function getCachedResponse(thisArg, args) { const data = cacheData.get(thisArg); const timeNow = Date.now(); - if (data && - data.args === JSON.stringify(args) && - data.expires > timeNow) { + if (data && data.args === JSON.stringify(args) && data.expires > timeNow) { data.expires = timeNow + cacheExpiry; cacheData.set(thisArg, data); - return data + return data; } - return { audioKey: null } + return { audioKey: null }; } - function setCache (thisArg, args, audioKey) { + function setCache(thisArg, args, audioKey) { cacheData.set(thisArg, { args: JSON.stringify(args), expires: Date.now() + cacheExpiry, audioKey }); } const getChannelDataProxy = new DDGProxy(this, AudioBuffer.prototype, 'getChannelData', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // The normal return value const channelData = DDGReflect.apply(target, thisArg, args); // Anything we do here should be caught and ignored silently try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f transformArrayData(channelData, domainKey, sessionKey, thisArg, args); - } catch { - } - return channelData - } + } catch {} + return channelData; + }, }); getChannelDataProxy.overload(); const audioMethods = ['getByteTimeDomainData', 'getFloatTimeDomainData', 'getByteFrequencyData', 'getFloatFrequencyData']; for (const methodName of audioMethods) { const proxy = new DDGProxy(this, AnalyserNode.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { DDGReflect.apply(target, thisArg, args); // Anything we do here should be caught and ignored silently try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f transformArrayData(args[0], domainKey, sessionKey, thisArg, args); - } catch { - } - } + } catch {} + }, }); proxy.overload(); } @@ -4319,7 +4341,7 @@ * as well as prevent any script from listening to events. */ class FingerprintingBattery extends ContentFeature { - init () { + init() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (globalThis.navigator.getBattery) { const BatteryManager = globalThis.BatteryManager; @@ -4328,7 +4350,7 @@ charging: true, chargingTime: 0, dischargingTime: Infinity, - level: 1 + level: 1, }; const eventProperties = ['onchargingchange', 'onchargingtimechange', 'ondischargingtimechange', 'onlevelchange']; @@ -4338,22 +4360,22 @@ enumerable: true, configurable: true, get: () => { - return val - } + return val; + }, }); - } catch (e) { } + } catch (e) {} } for (const eventProp of eventProperties) { try { this.defineProperty(BatteryManager.prototype, eventProp, { enumerable: true, configurable: true, - set: x => x, // noop + set: (x) => x, // noop get: () => { - return null - } + return null; + }, }); - } catch (e) { } + } catch (e) {} } } } @@ -5381,7 +5403,7 @@ * @param {any} getImageDataProxy * @param {CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext} ctx? */ - function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageDataProxy, ctx) { + function computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx) { if (!ctx) { // @ts-expect-error - Type 'null' is not assignable to type 'CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext'. ctx = canvas.getContext('2d'); @@ -5415,7 +5437,7 @@ // @ts-expect-error - 'offScreenCtx' is possibly 'null'. offScreenCtx.putImageData(imageData, 0, 0); - return { offScreenCanvas, offScreenCtx } + return { offScreenCanvas, offScreenCtx }; } /** @@ -5423,7 +5445,7 @@ * * @param {CanvasRenderingContext2D} canvasContext */ - function clearCanvas (canvasContext) { + function clearCanvas(canvasContext) { // Save state and clean the pixels from the canvas canvasContext.save(); canvasContext.globalCompositeOperation = 'destination-out'; @@ -5438,7 +5460,7 @@ * @param {string} domainKey * @param {number} width */ - function modifyPixelData (imageData, domainKey, sessionKey, width) { + function modifyPixelData(imageData, domainKey, sessionKey, width) { const d = imageData.data; const length = d.length / 4; let checkSum = 0; @@ -5461,7 +5483,7 @@ d[pixelCanvasIndex] = d[pixelCanvasIndex] ^ (byte & 0x1); } - return imageData + return imageData; } /** @@ -5471,7 +5493,7 @@ * @param {number} index * @param {number} width */ - function adjacentSame (imageData, index, width) { + function adjacentSame(imageData, index, width) { const widthPixel = width * 4; const x = index % widthPixel; const maxLength = imageData.length; @@ -5480,15 +5502,15 @@ if (x < widthPixel) { const right = index + 4; if (!pixelsSame(imageData, index, right)) { - return false + return false; } const diagonalRightUp = right - widthPixel; if (diagonalRightUp > 0 && !pixelsSame(imageData, index, diagonalRightUp)) { - return false + return false; } const diagonalRightDown = right + widthPixel; if (diagonalRightDown < maxLength && !pixelsSame(imageData, index, diagonalRightDown)) { - return false + return false; } } @@ -5496,29 +5518,29 @@ if (x > 0) { const left = index - 4; if (!pixelsSame(imageData, index, left)) { - return false + return false; } const diagonalLeftUp = left - widthPixel; if (diagonalLeftUp > 0 && !pixelsSame(imageData, index, diagonalLeftUp)) { - return false + return false; } const diagonalLeftDown = left + widthPixel; if (diagonalLeftDown < maxLength && !pixelsSame(imageData, index, diagonalLeftDown)) { - return false + return false; } } const up = index - widthPixel; if (up > 0 && !pixelsSame(imageData, index, up)) { - return false + return false; } const down = index + widthPixel; if (down < maxLength && !pixelsSame(imageData, index, down)) { - return false + return false; } - return true + return true; } /** @@ -5527,11 +5549,13 @@ * @param {number} index * @param {number} index2 */ - function pixelsSame (imageData, index, index2) { - return imageData[index] === imageData[index2] && - imageData[index + 1] === imageData[index2 + 1] && - imageData[index + 2] === imageData[index2 + 2] && - imageData[index + 3] === imageData[index2 + 3] + function pixelsSame(imageData, index, index2) { + return ( + imageData[index] === imageData[index2] && + imageData[index + 1] === imageData[index2 + 1] && + imageData[index + 2] === imageData[index2 + 2] && + imageData[index + 3] === imageData[index2 + 3] + ); } /** @@ -5540,16 +5564,16 @@ * @param {number} index * @returns {boolean} */ - function shouldIgnorePixel (imageData, index) { + function shouldIgnorePixel(imageData, index) { // Transparent pixels if (imageData[index + 3] === 0) { - return true + return true; } - return false + return false; } class FingerprintingCanvas extends ContentFeature { - init (args) { + init(args) { const { sessionKey, site } = args; const domainKey = site.domain; const supportsWebGl = this.getFeatureSettingEnabled('webGl'); @@ -5562,28 +5586,27 @@ * Clear cache as canvas has changed * @param {OffscreenCanvas | HTMLCanvasElement} canvas */ - function clearCache (canvas) { + function clearCache(canvas) { canvasCache.delete(canvas); } /** * @param {OffscreenCanvas | HTMLCanvasElement} canvas */ - function treatAsUnsafe (canvas) { + function treatAsUnsafe(canvas) { unsafeCanvases.add(canvas); clearCache(canvas); } const proxy = new DDGProxy(this, HTMLCanvasElement.prototype, 'getContext', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const context = DDGReflect.apply(target, thisArg, args); try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined'. canvasContexts.set(thisArg, context); - } catch { - } - return context - } + } catch {} + return context; + }, }); proxy.overload(); @@ -5591,7 +5614,7 @@ const safeMethods = ['putImageData', 'drawImage']; for (const methodName of safeMethods) { const safeMethodProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // Don't apply escape hatch for canvases if (methodName === 'drawImage' && args[0] && args[0] instanceof HTMLCanvasElement) { treatAsUnsafe(args[0]); @@ -5599,8 +5622,8 @@ // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' clearCache(thisArg.canvas); } - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); safeMethodProxy.overload(); } @@ -5624,17 +5647,17 @@ 'createConicGradient', 'createLinearGradient', 'createRadialGradient', - 'createPattern' + 'createPattern', ]; for (const methodName of unsafeMethods) { // Some methods are browser specific if (methodName in CanvasRenderingContext2D.prototype) { const unsafeProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' treatAsUnsafe(thisArg.canvas); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); unsafeProxy.overload(); } @@ -5649,11 +5672,9 @@ 'createProgram', 'linkProgram', 'drawElements', - 'drawArrays' - ]; - const glContexts = [ - WebGLRenderingContext + 'drawArrays', ]; + const glContexts = [WebGLRenderingContext]; if ('WebGL2RenderingContext' in globalThis) { glContexts.push(WebGL2RenderingContext); } @@ -5662,11 +5683,11 @@ // Some methods are browser specific if (methodName in context.prototype) { const unsafeProxy = new DDGProxy(this, context.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' treatAsUnsafe(thisArg.canvas); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); unsafeProxy.overload(); } @@ -5676,22 +5697,21 @@ // Using proxies here to swallow calls to toString etc const getImageDataProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, 'getImageData', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' if (!unsafeCanvases.has(thisArg.canvas)) { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } // Anything we do here should be caught and ignored silently try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' const { offScreenCtx } = getCachedOffScreenCanvasOrCompute(thisArg.canvas, domainKey, sessionKey); // Call the original method on the modified off-screen canvas - return DDGReflect.apply(target, offScreenCtx, args) - } catch { - } + return DDGReflect.apply(target, offScreenCtx, args); + } catch {} - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); getImageDataProxy.overload(); @@ -5702,7 +5722,7 @@ * @param {string} domainKey * @param {string} sessionKey */ - function getCachedOffScreenCanvasOrCompute (canvas, domainKey, sessionKey) { + function getCachedOffScreenCanvasOrCompute(canvas, domainKey, sessionKey) { let result; if (canvasCache.has(canvas)) { result = canvasCache.get(canvas); @@ -5711,28 +5731,28 @@ result = computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx); canvasCache.set(canvas, result); } - return result + return result; } const canvasMethods = ['toDataURL', 'toBlob']; for (const methodName of canvasMethods) { const proxy = new DDGProxy(this, HTMLCanvasElement.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // Short circuit for low risk canvas calls // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' if (!unsafeCanvases.has(thisArg)) { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' const { offScreenCanvas } = getCachedOffScreenCanvasOrCompute(thisArg, domainKey, sessionKey); // Call the original method on the modified off-screen canvas - return DDGReflect.apply(target, offScreenCanvas, args) + return DDGReflect.apply(target, offScreenCanvas, args); } catch { // Something we did caused an exception, fall back to the native - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } - } + }, }); proxy.overload(); } @@ -5740,7 +5760,7 @@ } class GoogleRejected extends ContentFeature { - init () { + init() { try { if ('browsingTopics' in Document.prototype) { delete Document.prototype.browsingTopics; @@ -5768,26 +5788,26 @@ // Set Global Privacy Control property on DOM class GlobalPrivacyControl extends ContentFeature { - init (args) { + init(args) { try { // If GPC on, set DOM property prototype to true if not already true if (args.globalPrivacyControlValue) { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - if (navigator.globalPrivacyControl) return + if (navigator.globalPrivacyControl) return; this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => true, configurable: true, - enumerable: true + enumerable: true, }); } else { // If GPC off & unsupported by browser, set DOM property prototype to false // this may be overwritten by the user agent or other extensions // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - if (typeof navigator.globalPrivacyControl !== 'undefined') return + if (typeof navigator.globalPrivacyControl !== 'undefined') return; this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => false, configurable: true, - enumerable: true + enumerable: true, }); } } catch { @@ -5797,77 +5817,77 @@ } class FingerprintingHardware extends ContentFeature { - init () { + init() { this.wrapProperty(globalThis.Navigator.prototype, 'keyboard', { get: () => { // @ts-expect-error - error TS2554: Expected 2 arguments, but got 1. - return this.getFeatureAttr('keyboard') - } + return this.getFeatureAttr('keyboard'); + }, }); this.wrapProperty(globalThis.Navigator.prototype, 'hardwareConcurrency', { get: () => { - return this.getFeatureAttr('hardwareConcurrency', 2) - } + return this.getFeatureAttr('hardwareConcurrency', 2); + }, }); this.wrapProperty(globalThis.Navigator.prototype, 'deviceMemory', { get: () => { - return this.getFeatureAttr('deviceMemory', 8) - } + return this.getFeatureAttr('deviceMemory', 8); + }, }); } } class Referrer extends ContentFeature { - init () { + init() { // If the referer is a different host to the current one, trim it. if (document.referrer && new URL(document.URL).hostname !== new URL(document.referrer).hostname) { // trim referrer to origin. const trimmedReferer = new URL(document.referrer).origin + '/'; this.wrapProperty(Document.prototype, 'referrer', { - get: () => trimmedReferer + get: () => trimmedReferer, }); } } } class FingerprintingScreenSize extends ContentFeature { - origPropertyValues = {} + origPropertyValues = {}; - init () { + init() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.origPropertyValues.availTop = globalThis.screen.availTop; this.wrapProperty(globalThis.Screen.prototype, 'availTop', { - get: () => this.getFeatureAttr('availTop', 0) + get: () => this.getFeatureAttr('availTop', 0), }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.origPropertyValues.availLeft = globalThis.screen.availLeft; this.wrapProperty(globalThis.Screen.prototype, 'availLeft', { - get: () => this.getFeatureAttr('availLeft', 0) + get: () => this.getFeatureAttr('availLeft', 0), }); this.origPropertyValues.availWidth = globalThis.screen.availWidth; const forcedAvailWidthValue = globalThis.screen.width; this.wrapProperty(globalThis.Screen.prototype, 'availWidth', { - get: () => forcedAvailWidthValue + get: () => forcedAvailWidthValue, }); this.origPropertyValues.availHeight = globalThis.screen.availHeight; const forcedAvailHeightValue = globalThis.screen.height; this.wrapProperty(globalThis.Screen.prototype, 'availHeight', { - get: () => forcedAvailHeightValue + get: () => forcedAvailHeightValue, }); this.origPropertyValues.colorDepth = globalThis.screen.colorDepth; this.wrapProperty(globalThis.Screen.prototype, 'colorDepth', { - get: () => this.getFeatureAttr('colorDepth', 24) + get: () => this.getFeatureAttr('colorDepth', 24), }); this.origPropertyValues.pixelDepth = globalThis.screen.pixelDepth; this.wrapProperty(globalThis.Screen.prototype, 'pixelDepth', { - get: () => this.getFeatureAttr('pixelDepth', 24) + get: () => this.getFeatureAttr('pixelDepth', 24), }); globalThis.window.addEventListener('resize', () => { @@ -5882,25 +5902,25 @@ * can mean second or more monitors have very large or negative values. This function maps a given * given coordinate value to the proper place on the main screen. */ - normalizeWindowDimension (value, targetDimension) { + normalizeWindowDimension(value, targetDimension) { if (value > targetDimension) { - return value % targetDimension + return value % targetDimension; } if (value < 0) { - return targetDimension + value + return targetDimension + value; } - return value + return value; } - setWindowPropertyValue (property, value) { + setWindowPropertyValue(property, value) { // Here we don't update the prototype getter because the values are updated dynamically try { this.defineProperty(globalThis, property, { get: () => value, - + set: () => {}, configurable: true, - enumerable: true + enumerable: true, }); } catch (e) {} } @@ -5911,7 +5931,7 @@ * ensuring that no information is leaked as the dimensions change, but also that the * values change correctly for valid use cases. */ - setWindowDimensions () { + setWindowDimensions() { try { const window = globalThis; const top = globalThis.top; @@ -5966,7 +5986,7 @@ } class FingerprintingTemporaryStorage extends ContentFeature { - init () { + init() { const navigator = globalThis.navigator; const Navigator = globalThis.Navigator; @@ -5982,7 +6002,7 @@ const org = navigator.webkitTemporaryStorage.queryUsageAndQuota; // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f const tStorage = navigator.webkitTemporaryStorage; - tStorage.queryUsageAndQuota = function queryUsageAndQuota (callback, err) { + tStorage.queryUsageAndQuota = function queryUsageAndQuota(callback, err) { const modifiedCallback = function (usedBytes, grantedBytes) { const maxBytesGranted = 4 * 1024 * 1024 * 1024; const spoofedGrantedBytes = Math.min(grantedBytes, maxBytesGranted); @@ -5994,7 +6014,7 @@ this.defineProperty(Navigator.prototype, 'webkitTemporaryStorage', { get: () => tStorage, enumerable: true, - configurable: true + configurable: true, }); } catch (e) {} } @@ -6002,35 +6022,35 @@ } class NavigatorInterface extends ContentFeature { - load (args) { + load(args) { if (this.matchDomainFeatureSetting('privilegedDomains').length) { this.injectNavigatorInterface(args); } } - init (args) { + init(args) { this.injectNavigatorInterface(args); } - injectNavigatorInterface (args) { + injectNavigatorInterface(args) { try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (navigator.duckduckgo) { - return + return; } if (!args.platform || !args.platform.name) { - return + return; } this.defineProperty(Navigator.prototype, 'duckduckgo', { value: { platform: args.platform.name, - isDuckDuckGo () { - return DDGPromise.resolve(true) - } + isDuckDuckGo() { + return DDGPromise.resolve(true); + }, }, enumerable: true, configurable: false, - writable: false + writable: false, }); } catch { // todo: Just ignore this exception? @@ -6057,44 +6077,44 @@ * @param {Object} rule * @param {HTMLElement} [previousElement] */ - function collapseDomNode (element, rule, previousElement) { + function collapseDomNode(element, rule, previousElement) { if (!element) { - return + return; } const type = rule.type; const alreadyHidden = hiddenElements.has(element); const alreadyModified = modifiedElements.has(element) && modifiedElements.get(element) === rule.type; // return if the element has already been hidden, or modified by the same rule type if (alreadyHidden || alreadyModified) { - return + return; } switch (type) { - case 'hide': - hideNode(element); - break - case 'hide-empty': - if (isDomNodeEmpty(element)) { + case 'hide': hideNode(element); - appliedRules.add(rule); - } - break - case 'closest-empty': - // hide the outermost empty node so that we may unhide if ad loads - if (isDomNodeEmpty(element)) { - // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - collapseDomNode(element.parentNode, rule, element); - } else if (previousElement) { - hideNode(previousElement); - appliedRules.add(rule); - } - break - case 'modify-attr': - modifyAttribute(element, rule.values); - break - case 'modify-style': - modifyStyle(element, rule.values); - break + break; + case 'hide-empty': + if (isDomNodeEmpty(element)) { + hideNode(element); + appliedRules.add(rule); + } + break; + case 'closest-empty': + // hide the outermost empty node so that we may unhide if ad loads + if (isDomNodeEmpty(element)) { + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + collapseDomNode(element.parentNode, rule, element); + } else if (previousElement) { + hideNode(previousElement); + appliedRules.add(rule); + } + break; + case 'modify-attr': + modifyAttribute(element, rule.values); + break; + case 'modify-style': + modifyStyle(element, rule.values); + break; } } @@ -6103,29 +6123,29 @@ * @param {HTMLElement} element * @param {Object} rule */ - function expandNonEmptyDomNode (element, rule) { + function expandNonEmptyDomNode(element, rule) { if (!element) { - return + return; } const type = rule.type; const alreadyHidden = hiddenElements.has(element); switch (type) { - case 'hide': - // only care about rule types that specifically apply to empty elements - break - case 'hide-empty': - case 'closest-empty': - if (alreadyHidden && !isDomNodeEmpty(element)) { - unhideNode(element); - } else if (type === 'closest-empty') { - // iterate upwards from matching DOM elements until we arrive at previously - // hidden element. Unhide element if it contains visible content. - // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - expandNonEmptyDomNode(element.parentNode, rule); - } - break + case 'hide': + // only care about rule types that specifically apply to empty elements + break; + case 'hide-empty': + case 'closest-empty': + if (alreadyHidden && !isDomNodeEmpty(element)) { + unhideNode(element); + } else if (type === 'closest-empty') { + // iterate upwards from matching DOM elements until we arrive at previously + // hidden element. Unhide element if it contains visible content. + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + expandNonEmptyDomNode(element.parentNode, rule); + } + break; } } @@ -6133,13 +6153,13 @@ * Hide DOM element * @param {HTMLElement} element */ - function hideNode (element) { + function hideNode(element) { // maintain a reference to each hidden element along with the properties // that are being overwritten const cachedDisplayProperties = { display: element.style.display, 'min-height': element.style.minHeight, - height: element.style.height + height: element.style.height, }; hiddenElements.set(element, cachedDisplayProperties); @@ -6156,10 +6176,10 @@ * Show previously hidden DOM element * @param {HTMLElement} element */ - function unhideNode (element) { + function unhideNode(element) { const cachedDisplayProperties = hiddenElements.get(element); if (!cachedDisplayProperties) { - return + return; } for (const prop in cachedDisplayProperties) { @@ -6173,10 +6193,10 @@ * Check if DOM element contains visible content * @param {HTMLElement} node */ - function isDomNodeEmpty (node) { + function isDomNodeEmpty(node) { // no sense wasting cycles checking if the page's body element is empty if (node.tagName === 'BODY') { - return false + return false; } // use a DOMParser to remove all metadata elements before checking if // the node is empty. @@ -6195,19 +6215,23 @@ // - node doesn't contain any iframes // - node contains iframes, all of which are hidden or have src='about:blank' const noFramesWithContent = frameElements.every((frame) => { - return (frame.hidden || frame.src === 'about:blank') + return frame.hidden || frame.src === 'about:blank'; }); // ad containers often contain tracking pixels and other small images (eg adchoices logo). // these should be treated as empty and hidden, but real images should not. const visibleImages = imageElements.some((image) => { - return (image.getBoundingClientRect().width > 20 || image.getBoundingClientRect().height > 20) + return image.getBoundingClientRect().width > 20 || image.getBoundingClientRect().height > 20; }); - if ((visibleText === '' || adLabelStrings.includes(visibleText)) && - mediaAndFormContent === null && noFramesWithContent && !visibleImages) { - return true + if ( + (visibleText === '' || adLabelStrings.includes(visibleText)) && + mediaAndFormContent === null && + noFramesWithContent && + !visibleImages + ) { + return true; } - return false + return false; } /** @@ -6217,7 +6241,7 @@ * @param {string} values[].property * @param {string} values[].value */ - function modifyAttribute (element, values) { + function modifyAttribute(element, values) { values.forEach((item) => { element.setAttribute(item.property, item.value); }); @@ -6231,7 +6255,7 @@ * @param {string} values[].property * @param {string} values[].value */ - function modifyStyle (element, values) { + function modifyStyle(element, values) { values.forEach((item) => { element.style.setProperty(item.property, item.value, 'important'); }); @@ -6244,9 +6268,9 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function extractTimeoutRules (rules) { + function extractTimeoutRules(rules) { if (!shouldInjectStyleTag) { - return rules + return rules; } const strictHideRules = []; @@ -6261,7 +6285,7 @@ }); injectStyleTag(strictHideRules); - return timeoutRules + return timeoutRules; } /** @@ -6270,7 +6294,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function injectStyleTag (rules) { + function injectStyleTag(rules) { // wrap selector list in :is(...) to make it a forgiving selector list. this enables // us to use selectors not supported in all browsers, eg :has in Firefox let selector = ''; @@ -6294,7 +6318,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function hideAdNodes (rules) { + function hideAdNodes(rules) { const document = globalThis.document; rules.forEach((rule) => { @@ -6310,7 +6334,7 @@ /** * Iterate over previously hidden elements, unhiding if content has loaded into them */ - function unhideLoadedAds () { + function unhideLoadedAds() { const document = globalThis.document; appliedRules.forEach((rule) => { @@ -6326,17 +6350,17 @@ /** * Wrap selector(s) in :is(..) to make them forgiving */ - function forgivingSelector (selector) { - return `:is(${selector})` + function forgivingSelector(selector) { + return `:is(${selector})`; } class ElementHiding extends ContentFeature { - init () { + init() { // eslint-disable-next-line @typescript-eslint/no-this-alias featureInstance = this; if (isBeingFramed()) { - return + return; } let activeRules; @@ -6356,17 +6380,17 @@ const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules); const overrideRules = activeDomainRules.filter((rule) => { - return rule.type === 'override' + return rule.type === 'override'; }); const disableDefault = activeDomainRules.some((rule) => { - return rule.type === 'disable-default' + return rule.type === 'disable-default'; }); // if rule with type 'disable-default' is present, ignore all global rules if (disableDefault) { activeRules = activeDomainRules.filter((rule) => { - return rule.type !== 'disable-default' + return rule.type !== 'disable-default'; }); } else { activeRules = activeDomainRules.concat(globalRules); @@ -6375,7 +6399,7 @@ // remove overrides and rules that match overrides from array of rules to be applied to page overrideRules.forEach((override) => { activeRules = activeRules.filter((rule) => { - return rule.selector !== override.selector + return rule.selector !== override.selector; }); }); @@ -6392,10 +6416,10 @@ // single page applications don't have a DOMContentLoaded event on navigations, so // we use proxy/reflect on history.pushState to call applyRules on page navigations const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { applyRules(activeRules); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); historyMethodProxy.overload(); // listen for popstate events in order to run on back/forward navigations @@ -6410,7 +6434,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - applyRules (rules) { + applyRules(rules) { const timeoutRules = extractTimeoutRules(rules); const clearCacheTimer = unhideTimeouts.concat(hideTimeouts).reduce((a, b) => Math.max(a, b), 0) + 100; @@ -6442,29 +6466,169 @@ } class ExceptionHandler extends ContentFeature { - init () { + init() { // Report to the debugger panel if an uncaught exception occurs const handleUncaughtException = (e) => { - postDebugMessage('jsException', { - documentUrl: document.location.href, - message: e.message, - filename: e.filename, - lineno: e.lineno, - colno: e.colno, - stack: e.error?.stack - }, true); + postDebugMessage( + 'jsException', + { + documentUrl: document.location.href, + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + stack: e.error?.stack, + }, + true, + ); this.addDebugFlag(); }; globalThis.addEventListener('error', handleUncaughtException); } } + /** + * This feature allows remote configuration of APIs that exist within the DOM. + * We support removal of APIs and returning different values from getters. + * + * @module API manipulation + */ + + /** + * @internal + */ + class ApiManipulation extends ContentFeature { + init() { + const apiChanges = this.getFeatureSetting('apiChanges'); + if (apiChanges) { + for (const scope in apiChanges) { + const change = apiChanges[scope]; + if (!this.checkIsValidAPIChange(change)) { + continue; + } + this.applyApiChange(scope, change); + } + } + } + + /** + * Checks if the config API change is valid. + * @param {any} change + * @returns {change is APIChange} + */ + checkIsValidAPIChange(change) { + if (typeof change !== 'object') { + return false; + } + if (change.type === 'remove') { + return true; + } + if (change.type === 'descriptor') { + if (change.enumerable && typeof change.enumerable !== 'boolean') { + return false; + } + if (change.configurable && typeof change.configurable !== 'boolean') { + return false; + } + return typeof change.getterValue !== 'undefined'; + } + return false; + } + + // TODO move this to schema definition imported from the privacy-config + // Additionally remove checkIsValidAPIChange when this change happens. + // See: https://app.asana.com/0/1201614831475344/1208715421518231/f + /** + * @typedef {Object} APIChange + * @property {"remove"|"descriptor"} type + * @property {import('../utils.js').ConfigSetting} [getterValue] - The value returned from a getter. + * @property {boolean} [enumerable] - Whether the property is enumerable. + * @property {boolean} [configurable] - Whether the property is configurable. + */ + + /** + * Applies a change to DOM APIs. + * @param {string} scope + * @param {APIChange} change + * @returns {void} + */ + applyApiChange(scope, change) { + const response = this.getGlobalObject(scope); + if (!response) { + return; + } + const [obj, key] = response; + if (change.type === 'remove') { + this.removeApiMethod(obj, key); + } else if (change.type === 'descriptor') { + this.wrapApiDescriptor(obj, key, change); + } + } + + /** + * Removes a method from an API. + * @param {object} api + * @param {string} key + */ + removeApiMethod(api, key) { + try { + if (hasOwnProperty.call(api, key)) { + delete api[key]; + } + } catch (e) {} + } + + /** + * Wraps a property with descriptor. + * @param {object} api + * @param {string} key + * @param {APIChange} change + */ + wrapApiDescriptor(api, key, change) { + const getterValue = change.getterValue; + if (getterValue) { + const descriptor = { + get: () => processAttr(getterValue, undefined), + }; + if ('enumerable' in change) { + descriptor.enumerable = change.enumerable; + } + if ('configurable' in change) { + descriptor.configurable = change.configurable; + } + this.wrapProperty(api, key, descriptor); + } + } + + /** + * Looks up a global object from a scope, e.g. 'Navigator.prototype'. + * @param {string} scope the scope of the object to get to. + * @returns {[object, string]|null} the object at the scope. + */ + getGlobalObject(scope) { + const parts = scope.split('.'); + // get the last part of the scope + const lastPart = parts.pop(); + if (!lastPart) { + return null; + } + let obj = window; + for (const part of parts) { + obj = obj[part]; + if (!obj) { + return null; + } + } + return [obj, lastPart]; + } + } + /** * Fixes incorrect sizing value for outerHeight and outerWidth */ - function windowSizingFix () { + function windowSizingFix() { if (window.outerHeight !== 0 && window.outerWidth !== 0) { - return + return; } window.outerHeight = window.innerHeight; window.outerWidth = window.innerWidth; @@ -6475,30 +6639,30 @@ const MSG_SCREEN_LOCK = 'screenLock'; const MSG_SCREEN_UNLOCK = 'screenUnlock'; - function canShare (data) { - if (typeof data !== 'object') return false - if (!('url' in data) && !('title' in data) && !('text' in data)) return false // At least one of these is required - if ('files' in data) return false // File sharing is not supported at the moment - if ('title' in data && typeof data.title !== 'string') return false - if ('text' in data && typeof data.text !== 'string') return false + function canShare(data) { + if (typeof data !== 'object') return false; + if (!('url' in data) && !('title' in data) && !('text' in data)) return false; // At least one of these is required + if ('files' in data) return false; // File sharing is not supported at the moment + if ('title' in data && typeof data.title !== 'string') return false; + if ('text' in data && typeof data.text !== 'string') return false; if ('url' in data) { - if (typeof data.url !== 'string') return false + if (typeof data.url !== 'string') return false; try { const url = new URL$1(data.url, location.href); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; } catch (err) { - return false + return false; } } - if (window !== window.top) return false // Not supported in iframes - return true + if (window !== window.top) return false; // Not supported in iframes + return true; } /** * Clean data before sending to the Android side * @returns {ShareRequestData} */ - function cleanShareData (data) { + function cleanShareData(data) { /** @type {ShareRequestData} */ const dataToSend = {}; @@ -6509,7 +6673,7 @@ // clean url and handle relative links (e.g. if url is an empty string) if ('url' in data) { - dataToSend.url = (new URL$1(data.url, location.href)).href; + dataToSend.url = new URL$1(data.url, location.href).href; } // combine url and text into text if both are present @@ -6522,17 +6686,17 @@ if (!('url' in dataToSend) && !('text' in dataToSend)) { dataToSend.text = ''; } - return dataToSend + return dataToSend; } class WebCompat extends ContentFeature { /** @type {Promise | null} */ - #activeShareRequest = null + #activeShareRequest = null; /** @type {Promise | null} */ - #activeScreenLockRequest = null + #activeScreenLockRequest = null; - init () { + init() { if (this.getFeatureSettingEnabled('windowSizing')) { windowSizingFix(); } @@ -6582,14 +6746,14 @@ } /** Shim Web Share API in Android WebView */ - shimWebShare () { - if (typeof navigator.canShare === 'function' || typeof navigator.share === 'function') return + shimWebShare() { + if (typeof navigator.canShare === 'function' || typeof navigator.share === 'function') return; this.defineProperty(Navigator.prototype, 'canShare', { configurable: true, enumerable: true, writable: true, - value: canShare + value: canShare, }); this.defineProperty(Navigator.prototype, 'share', { @@ -6597,12 +6761,12 @@ enumerable: true, writable: true, value: async (data) => { - if (!canShare(data)) return Promise.reject(new TypeError('Invalid share data')) + if (!canShare(data)) return Promise.reject(new TypeError('Invalid share data')); if (this.#activeShareRequest) { - return Promise.reject(new DOMException('Share already in progress', 'InvalidStateError')) + return Promise.reject(new DOMException('Share already in progress', 'InvalidStateError')); } if (!navigator.userActivation.isActive) { - return Promise.reject(new DOMException('Share must be initiated by a user gesture', 'InvalidStateError')) + return Promise.reject(new DOMException('Share must be initiated by a user gesture', 'InvalidStateError')); } const dataToSend = cleanShareData(data); @@ -6611,31 +6775,31 @@ try { resp = await this.#activeShareRequest; } catch (err) { - throw new DOMException(err.message, 'DataError') + throw new DOMException(err.message, 'DataError'); } finally { this.#activeShareRequest = null; } if (resp.failure) { switch (resp.failure.name) { - case 'AbortError': - case 'NotAllowedError': - case 'DataError': - throw new DOMException(resp.failure.message, resp.failure.name) - default: - throw new DOMException(resp.failure.message, 'DataError') + case 'AbortError': + case 'NotAllowedError': + case 'DataError': + throw new DOMException(resp.failure.message, resp.failure.name); + default: + throw new DOMException(resp.failure.message, 'DataError'); } } - } + }, }); } /** * Notification fix for adding missing API for Android WebView. */ - notificationFix () { + notificationFix() { if (window.Notification) { - return + return; } // Expose the API this.defineProperty(window, 'Notification', { @@ -6644,33 +6808,33 @@ }, writable: true, configurable: true, - enumerable: false + enumerable: false, }); this.defineProperty(window.Notification, 'requestPermission', { value: () => { - return Promise.resolve('denied') + return Promise.resolve('denied'); }, writable: true, configurable: true, - enumerable: true + enumerable: true, }); this.defineProperty(window.Notification, 'permission', { get: () => 'denied', configurable: true, - enumerable: false + enumerable: false, }); this.defineProperty(window.Notification, 'maxActions', { get: () => 2, configurable: true, - enumerable: true + enumerable: true, }); } - cleanIframeValue () { - function cleanValueData (val) { + cleanIframeValue() { + function cleanValueData(val) { const clone = Object.assign({}, val); const deleteKeys = ['iframeProto', 'iframeData', 'remap']; for (const key of deleteKeys) { @@ -6679,15 +6843,17 @@ } } val.iframeData = clone; - return val + return val; } window.XMLHttpRequest.prototype.send = new Proxy(window.XMLHttpRequest.prototype.send, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const body = args[0]; const cleanKey = 'bi_wvdp'; if (body && typeof body === 'string' && body.includes(cleanKey)) { - const parts = body.split('&').map((part) => { return part.split('=') }); + const parts = body.split('&').map((part) => { + return part.split('='); + }); if (parts.length > 0) { parts.forEach((part) => { if (part[0] === cleanKey) { @@ -6695,58 +6861,69 @@ part[1] = encodeURIComponent(JSON.stringify(cleanValueData(val))); } }); - args[0] = parts.map((part) => { return part.join('=') }).join('&'); + args[0] = parts + .map((part) => { + return part.join('='); + }) + .join('&'); } } - return Reflect.apply(target, thisArg, args) - } + return Reflect.apply(target, thisArg, args); + }, }); } /** * Adds missing permissions API for Android WebView. */ - permissionsFix (settings) { + permissionsFix(settings) { if (window.navigator.permissions) { - return + return; } const permissions = {}; class PermissionStatus extends EventTarget { - constructor (name, state) { + constructor(name, state) { super(); this.name = name; this.state = state; this.onchange = null; // noop } } - permissions.query = new Proxy(async (query) => { - this.addDebugFlag(); - if (!query) { - throw new TypeError("Failed to execute 'query' on 'Permissions': 1 argument required, but only 0 present.") - } - if (!query.name) { - throw new TypeError("Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': Required member is undefined.") - } - if (!settings.supportedPermissions || !(query.name in settings.supportedPermissions)) { - throw new TypeError(`Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': The provided value '${query.name}' is not a valid enum value of type PermissionName.`) - } - const permSetting = settings.supportedPermissions[query.name]; - const returnName = permSetting.name || query.name; - let returnStatus = settings.permissionResponse || 'prompt'; - if (permSetting.native) { - try { - const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); - returnStatus = response.state || 'prompt'; - } catch (err) { - // do nothing - keep returnStatus as-is + permissions.query = new Proxy( + async (query) => { + this.addDebugFlag(); + if (!query) { + throw new TypeError("Failed to execute 'query' on 'Permissions': 1 argument required, but only 0 present."); } - } - return Promise.resolve(new PermissionStatus(returnName, returnStatus)) - }, { - get (target, name) { - return Reflect.get(target, name) - } - }); + if (!query.name) { + throw new TypeError( + "Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': Required member is undefined.", + ); + } + if (!settings.supportedPermissions || !(query.name in settings.supportedPermissions)) { + throw new TypeError( + `Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': The provided value '${query.name}' is not a valid enum value of type PermissionName.`, + ); + } + const permSetting = settings.supportedPermissions[query.name]; + const returnName = permSetting.name || query.name; + let returnStatus = settings.permissionResponse || 'prompt'; + if (permSetting.native) { + try { + const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); + returnStatus = response.state || 'prompt'; + } catch (err) { + // do nothing - keep returnStatus as-is + } + } + return Promise.resolve(new PermissionStatus(returnName, returnStatus)); + }, + { + get(target, name) { + return Reflect.get(target, name); + }, + }, + ); // Expose the API // @ts-expect-error window.navigator isn't assignable window.navigator.permissions = permissions; @@ -6755,7 +6932,7 @@ /** * Fixes screen lock/unlock APIs for Android WebView. */ - screenLockFix () { + screenLockFix() { const validOrientations = [ 'any', 'natural', @@ -6765,19 +6942,25 @@ 'portrait-secondary', 'landscape-primary', 'landscape-secondary', - 'unsupported' + 'unsupported', ]; this.wrapProperty(globalThis.ScreenOrientation.prototype, 'lock', { value: async (requestedOrientation) => { if (!requestedOrientation) { - return Promise.reject(new TypeError("Failed to execute 'lock' on 'ScreenOrientation': 1 argument required, but only 0 present.")) + return Promise.reject( + new TypeError("Failed to execute 'lock' on 'ScreenOrientation': 1 argument required, but only 0 present."), + ); } if (!validOrientations.includes(requestedOrientation)) { - return Promise.reject(new TypeError(`Failed to execute 'lock' on 'ScreenOrientation': The provided value '${requestedOrientation}' is not a valid enum value of type OrientationLockType.`)) + return Promise.reject( + new TypeError( + `Failed to execute 'lock' on 'ScreenOrientation': The provided value '${requestedOrientation}' is not a valid enum value of type OrientationLockType.`, + ), + ); } if (this.#activeScreenLockRequest) { - return Promise.reject(new DOMException('Screen lock already in progress', 'AbortError')) + return Promise.reject(new DOMException('Screen lock already in progress', 'AbortError')); } this.#activeScreenLockRequest = this.messaging.request(MSG_SCREEN_LOCK, { orientation: requestedOrientation }); @@ -6785,86 +6968,86 @@ try { resp = await this.#activeScreenLockRequest; } catch (err) { - throw new DOMException(err.message, 'DataError') + throw new DOMException(err.message, 'DataError'); } finally { this.#activeScreenLockRequest = null; } if (resp.failure) { switch (resp.failure.name) { - case 'TypeError': - return Promise.reject(new TypeError(resp.failure.message)) - case 'InvalidStateError': - return Promise.reject(new DOMException(resp.failure.message, resp.failure.name)) - default: - return Promise.reject(new DOMException(resp.failure.message, 'DataError')) + case 'TypeError': + return Promise.reject(new TypeError(resp.failure.message)); + case 'InvalidStateError': + return Promise.reject(new DOMException(resp.failure.message, resp.failure.name)); + default: + return Promise.reject(new DOMException(resp.failure.message, 'DataError')); } } - return Promise.resolve() - } + return Promise.resolve(); + }, }); this.wrapProperty(globalThis.ScreenOrientation.prototype, 'unlock', { value: () => { this.messaging.request(MSG_SCREEN_UNLOCK, {}); - } + }, }); } /** * Add missing navigator.credentials API */ - navigatorCredentialsFix () { + navigatorCredentialsFix() { try { if ('credentials' in navigator && 'get' in navigator.credentials) { - return + return; } const value = { - get () { - return Promise.reject(new Error()) - } + get() { + return Promise.reject(new Error()); + }, }; // TODO: original property is an accessor descriptor this.defineProperty(Navigator.prototype, 'credentials', { value, configurable: true, enumerable: true, - writable: true + writable: true, }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - safariObjectFix () { + safariObjectFix() { try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (window.safari) { - return + return; } this.defineProperty(window, 'safari', { - value: { - }, + value: {}, writable: true, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari, 'pushNotification', { - value: { - }, + value: {}, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'toString', { - value: () => { return '[object SafariRemoteNotification]' }, + value: () => { + return '[object SafariRemoteNotification]'; + }, configurable: true, - enumerable: true + enumerable: true, }); class SafariRemoteNotificationPermission { - constructor () { + constructor() { this.deviceToken = null; this.permission = 'denied'; } @@ -6872,84 +7055,88 @@ // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'permission', { value: () => { - return new SafariRemoteNotificationPermission() + return new SafariRemoteNotificationPermission(); }, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'requestPermission', { value: (name, domain, options, callback) => { if (typeof callback === 'function') { callback(new SafariRemoteNotificationPermission()); - return + return; } const reason = "Invalid 'callback' value passed to safari.pushNotification.requestPermission(). Expected a function."; - throw new Error(reason) + throw new Error(reason); }, configurable: true, - enumerable: true + enumerable: true, }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - mediaSessionFix () { + mediaSessionFix() { try { if (window.navigator.mediaSession && "android" !== 'integration') { - return + return; } class MyMediaSession { - metadata = null + metadata = null; /** @type {MediaSession['playbackState']} */ - playbackState = 'none' + playbackState = 'none'; - setActionHandler () {} - setCameraActive () {} - setMicrophoneActive () {} - setPositionState () {} + setActionHandler() {} + setCameraActive() {} + setMicrophoneActive() {} + setPositionState() {} } this.shimInterface('MediaSession', MyMediaSession, { disallowConstructor: true, allowConstructorCall: false, - wrapToString: true + wrapToString: true, }); this.shimProperty(Navigator.prototype, 'mediaSession', new MyMediaSession(), true); - this.shimInterface('MediaMetadata', class { - constructor (metadata = {}) { - this.title = metadata.title; - this.artist = metadata.artist; - this.album = metadata.album; - this.artwork = metadata.artwork; - } - }, { - disallowConstructor: false, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + 'MediaMetadata', + class { + constructor(metadata = {}) { + this.title = metadata.title; + this.artist = metadata.artist; + this.album = metadata.album; + this.artwork = metadata.artwork; + } + }, + { + disallowConstructor: false, + allowConstructorCall: false, + wrapToString: true, + }, + ); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - presentationFix () { + presentationFix() { try { // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' if (window.navigator.presentation && "android" !== 'integration') { - return + return; } const MyPresentation = class { - get defaultRequest () { - return null + get defaultRequest() { + return null; } - get receiver () { - return null + get receiver() { + return null; } }; @@ -6957,26 +7144,34 @@ this.shimInterface('Presentation', MyPresentation, { disallowConstructor: true, allowConstructorCall: false, - wrapToString: true + wrapToString: true, }); - // @ts-expect-error Presentation API is still experimental, TS types are missing - this.shimInterface('PresentationAvailability', class { - // class definition is empty because there's no way to get an instance of it anyways - }, { - disallowConstructor: true, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + // @ts-expect-error Presentation API is still experimental, TS types are missing + 'PresentationAvailability', + class { + // class definition is empty because there's no way to get an instance of it anyways + }, + { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true, + }, + ); - // @ts-expect-error Presentation API is still experimental, TS types are missing - this.shimInterface('PresentationRequest', class { - // class definition is empty because there's no way to get an instance of it anyways - }, { - disallowConstructor: true, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + // @ts-expect-error Presentation API is still experimental, TS types are missing + 'PresentationRequest', + class { + // class definition is empty because there's no way to get an instance of it anyways + }, + { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true, + }, + ); /** TODO: add shims for other classes in the Presentation API: * PresentationConnection, @@ -6996,11 +7191,11 @@ /** * Support for modifying localStorage entries */ - modifyLocalStorage () { + modifyLocalStorage() { /** @type {import('../types//webcompat-settings').WebCompatSettings['modifyLocalStorage']} */ const settings = this.getFeatureSetting('modifyLocalStorage'); - if (!settings || !settings.changes) return + if (!settings || !settings.changes) return; settings.changes.forEach((change) => { if (change.action === 'delete') { @@ -7012,49 +7207,47 @@ /** * Support for proxying `window.webkit.messageHandlers` */ - messageHandlersFix () { + messageHandlersFix() { /** @type {import('../types//webcompat-settings').WebCompatSettings['messageHandlers']} */ const settings = this.getFeatureSetting('messageHandlers'); // Do nothing if `messageHandlers` is absent - if (!globalThis.webkit?.messageHandlers) return + if (!globalThis.webkit?.messageHandlers) return; // This should never occur, but keeps typescript happy - if (!settings) return + if (!settings) return; const proxy = new Proxy(globalThis.webkit.messageHandlers, { - get (target, messageName, receiver) { + get(target, messageName, receiver) { const handlerName = String(messageName); // handle known message names, such as DDG webkit messaging if (settings.handlerStrategies.reflect.includes(handlerName)) { - return Reflect.get(target, messageName, receiver) + return Reflect.get(target, messageName, receiver); } if (settings.handlerStrategies.undefined.includes(handlerName)) { - return undefined + return undefined; } - if (settings.handlerStrategies.polyfill.includes('*') || - settings.handlerStrategies.polyfill.includes(handlerName) - ) { + if (settings.handlerStrategies.polyfill.includes('*') || settings.handlerStrategies.polyfill.includes(handlerName)) { return { - postMessage () { - return Promise.resolve({}) - } - } + postMessage() { + return Promise.resolve({}); + }, + }; } // if we get here, we couldn't handle the message handler name, so we opt for doing nothing. // It's unlikely we'll ever reach here, since `["*"]' should be present - } + }, }); globalThis.webkit = { ...globalThis.webkit, - messageHandlers: proxy + messageHandlers: proxy, }; } - viewportWidthFix () { + viewportWidthFix() { if (document.readyState === 'loading') { // if the document is not ready, we may miss the original viewport tag document.addEventListener('DOMContentLoaded', () => this.viewportWidthFixInner()); @@ -7068,7 +7261,7 @@ * @param {HTMLMetaElement|null} viewportTag * @param {string} forcedValue */ - forceViewportTag (viewportTag, forcedValue) { + forceViewportTag(viewportTag, forcedValue) { const viewportTagExists = Boolean(viewportTag); if (!viewportTag) { viewportTag = document.createElement('meta'); @@ -7080,7 +7273,7 @@ } } - viewportWidthFixInner () { + viewportWidthFixInner() { /** @type {NodeListOf} **/ const viewportTags = document.querySelectorAll('meta[name=viewport i]'); // Chrome respects only the last viewport tag @@ -7090,18 +7283,18 @@ const viewportContentParts = viewportContent ? viewportContent.split(/,|;/) : []; /** @type {readonly string[][]} **/ const parsedViewportContent = viewportContentParts.map((part) => { - const [key, value] = part.split('=').map(p => p.trim().toLowerCase()); - return [key, value] + const [key, value] = part.split('=').map((p) => p.trim().toLowerCase()); + return [key, value]; }); // first, check if there are any forced values const { forcedDesktopValue, forcedMobileValue } = this.getFeatureSetting('viewportWidth'); if (typeof forcedDesktopValue === 'string' && this.desktopModeEnabled) { this.forceViewportTag(viewportTag, forcedDesktopValue); - return + return; } else if (typeof forcedMobileValue === 'string' && !this.desktopModeEnabled) { this.forceViewportTag(viewportTag, forcedMobileValue); - return + return; } // otherwise, check for special cases @@ -7125,7 +7318,8 @@ // Race condition: depending on the loading state of the page, initial scale may or may not be respected, so the page may look zoomed-in after applying this hack. // Usually this is just an annoyance, but it may be a bigger issue if user-scalable=no is set, so we remove it too. forcedValues['user-scalable'] = 'yes'; - } else { // mobile mode with a viewport tag + } else { + // mobile mode with a viewport tag // fix an edge case where WebView forces the wide viewport const widthPart = parsedViewportContent.find(([key]) => key === 'width'); const initialScalePart = parsedViewportContent.find(([key]) => key === 'initial-scale'); @@ -7143,7 +7337,8 @@ newContent.push(`${key}=${forcedValues[key]}`); }); - if (newContent.length > 0) { // need to override at least one viewport component + if (newContent.length > 0) { + // need to override at least one viewport component parsedViewportContent.forEach(([key], idx) => { if (!(key in forcedValues)) { newContent.push(viewportContentParts[idx].trim()); // reuse the original values, not the parsed ones @@ -7154,19 +7349,28 @@ } } - const logoImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFQAAABUCAYAAAAcaxDBAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABNTSURBVHgBzV0LcFPXmf6PJFt+gkEY8wrYMSEbgst7m02ywZnOZiEJCQlJC+QB25lNs7OzlEJ2ptmZLGayfUy3EEhmW5rM7gCZBtjJgzxmSTvTRSST9IF5pCE0TUosmmBjHIKNZFmWLN2e78hHPvfqXuleSdfONyNLV7q6uve7//uc85vRlwAda25oTFK8lZGn0UPaLI2okUhrTH/KGnU7M+olTevlL0KaeM3e01LaKa/PE2p64dgpGmMwGgN0rGqtS1Ve2cB/fhk/gVbSqI5KAU4wvxlBTdNe9VJ5sOnAb0I0yhg1QiWJTGN3E0gcHQRTpO0dTXJdJ7RjzZJWflHrGaNVdiTRN2kalTfOIU9VLfnqp5ruM9TTxR+dlIqGKX7uI7IDLrl7PFS2zW1iXSMURGqkbaUc0uiprqWqxa1UOXcxVcxdxAmcRoUApMZDH9HAmeMU+8NxQbYV3Ca25ITCwaRY4immcYk0AUgcv3wtJ3CxeLgBEBw++jpF249akusWsSUltGPNoq0aY5vMVLviusU04b5HbJMoVLo/ItRaBUyBp7rGtjTHuNSGj75BkbdeN/2ckdbWdODENioRSkIopFLThl4hpi0wflZzy0pO5D9aEiDsIFfXQagtf4CAXCqronzWHHFc3CQ/f53rZuGYl198zorYEKOyW0shrUUT2rFu8bc1jdqMUplLIkFi9NhRCvOLA4mp/jCVAjAn+N2qJa1UvXSZkGYjQOylfTu4OQjqPxAhl7atef+JnVQEiiK0Y+2ipzSNq7gCXFT9o1vFRRkB6evnFxJ5642SkWgF4fD4OUxYba4dEW4GLr/0bJY2FGsCCiIUMaVWEX6FDB4cF1D/T1uzJANE4uTxPBaoWbbSlNgcZiDIYsl7mg6d6iWHcEyolb0MPLyFxq1Yq9sXqg31ihx9nb4MsCK298VnxQ3XQaNTjJXd49SuOiJUkEmJIyRy7TSgWg2bf5xlK/sO76defpJuq7ZTgMy61Y9Q7bI7de/Dlndvf8xoAhw7K9uECjX3R46okomTm/rEbt0dh1TixIzqDeI9lSPZD/ZDWDT0uT2PXmqYSSvI7HryUT2pkNTB5K121d82oZ+sWQzJbJXbZmRa3GWBces2UuXX7qOKigryeDy6z0A+wqbosaDIdEYLZtdgSiq3qVcfOH6rnWPaIlQE7MTacp1ImHvuL/Ztz63iE+qpZtN2qp8z13IX6Siix4OjYi7gQCdy+6+aADNSecKys3l/+3fyHc+bb4d0nMl+KLfNyIS9vPTfPyAtEbc8jvjevz5F45r/inIBpqF6aSvV/M1twiTYLX4UCpwzYlIRw17TMnIOS5aJ8E5eE5e8Gza2TO17+nTXb3IdLyehaSeUOsBfVsj3pv77z6hsWmNmH5AJycwFQeb3nqfBqvHU399P4XBYPMfjcWK8DOXz+bK+I4mFCo2GGRh479dZpFbMbhGkSvBzvWHTvFkHd53+zNKe5lR5bjc7SPHoE7h3rOPZjwTU/POftlE+4ORS5ZVEly+OvDm1UTw0bldRsmtoaCC/32/6/SvQgDw3rVSY9GibTv2zfps7qasPHl9o9X1LCYXd5HxnKkbIyQPrt2Q+h325uOOxnGqeOQfsE+vXvxnhN7krROzd/6PUlJkU9nOJrK4mrzf7lPxcaiCt0IxE57msgkkpAQdZNf9G8tYFMr8Ns5PoDKV3YDRl47zp7OnTnUGz75tK6HC82SG3jXbTwhM6Q0U1sZvvFERVz77e1PtbwSptLBVwndN/+PNMxocb+OnGu0acJM/7mVa20Cw+Nb2CFCW2qtsIhFUndPml5wq/mAmTiT2yjep2HKKZ/7CF6r+ylKqqqmyTCdRwlcQNRmXfDeDaEP5JgFjUJzLghSDUfM2+m3UVkE4uthvkNvJz1aZAOgpNJbWv3U/jnnyeZi5bQRMmTHBEohFprfmZa6RC9eFwJcCDmg2igI5RCeP3sq7IKJ2BhzdnXosY0Zjz2gHUm0vltAe/TYFAoCgiVUByQGqhQyf5gBxftddwyiqGh3j056RuGKUTjqhoVR8mc8bf/r2wk6VGmtTdIpIoNWRxRwISCk4UtBqlVEeoUTpRaZcAkYWoOtQ8MG+xaaxZKuCmj1u+ltwArlmtS6icABjRVbczhNqRTqfQFvGM57avU21t6aXnvTOd9PKb79O+l9rpnfYOGn/7WlekFFDNnBxykcDweMeqBZnRigyhmAqjHsSY2xbkiLh0Tpw4MbMZiQ5yAo7T1h2/oG89/iL9aHeQLvQ4jynfaQ8JEqsry6lhUi2dPXeJdr/4vmtSCgnVSalqS+HxK30b5GZGD73E1mvyTcNdKEg6m3hsOeWqjKqDuMf+43VOQA09vHoJNTcGqKbKL0h2ipuWNIqHEaloC115c78rRRUM3UhO8Cyyv+HfYZqG2TBiLEpIaDqQHynNVfHCwMhJhrMHtOzguqUi85GAet52y7W0/Ym7aP7caYJMQD6XAnBQmDjhBhAuqh7foA2tUu0FoVnqrngyjE4WdMeb5upy83uXt3DJdGdigwpjJb5UAJn9nAuJSsMIhVR7QejwBC4BqLsaLPcXIp0Az7vLy8szm1Pq3XEYRoh5US45J3UwT6q9BFf7VjynCfWMqDvGtVUUVDrjhWRx8BIF8FaQTk46OGxD7TEBwg1gQoaq9jrzwkjYSU/H/UsXqJMUVGcEz1aIumt1k/OSibDnP3cfoZ/se7cgTw/8ZN+vRdjUzb+/ekUL/fJouhjtFqFylouETu05h/BFnqQv1ah+ya+czKBL1XKQsIV7/F+89VFGygrx9t09V8RzJBrnEnpEhFOAf9a15BZUTjBjUEWSkq0ebj914+uq/SxmYkIqlbL87J3joczrmqp0Ovpue4icAtGCBGJRue1WwQRQJdRYQ2CkNfpI0+bLqqhRVYod4gWpZqof6R8pSr/85u/F880mcWU+IJ6Fs4NkNs8KZKIIT1UNuQWjTwGpsr6B9QE+D6M6GdAbp9Cod8MJWO9FzL+0JHT1innC/kmAlBsLIBRAbIuHCjte3sMVo2o2FyLuP+N8ZCbyAdmCsTgEIZTv8ZHhRp8mVlukRdQ4Pl0wBqLiCYNwZkWRe5d/RQT0cEwNnMx7V7RQKWE26068P0xi7fXc/l2l/8wuoQC4kVzpfwsqz1gdDYuoOqc9FY1QwcD4USxKiUTCchczySoVZGjjG8clqIGTN4M7qsnZJErEPiVHwPA2pSPDrHUAPquFBEXnw5zUoaEhKhpJfh69PEMZ5BoT78q/L394+H6z/oVLj42sNsWDi543yRFyDBI2ulek5KOEA5OnU8EY4Pb7Uz58Gy4s0rBLZtdBrsJ9VDK4R+jlnsIl9NIbRKE2chNQc0hmKckE3CP0Qkh4eTgmNafPi3ina2RCIsOnecHnT87tpl1wQrVQ1npKoqILDKzjA+HrBgYGnBHamb/2CmLiF7Pf940f/jyW3gfSl+DJ1BB/xP6cfi4FrKIIjNfrJBQr1Ea+VGRwzFUenn5w0OFxon/M+XHPYWchjhvAsh4JlTMuQb08rmchua16r5IMzXZ1UCwWc/adpHW4BiLHmkxAF6/rskkW8nC1PCc3jVMHiya185xwTI6cU611ETrp8N64AWN6rg+htD5O6IiEGrMjY23UMTrOiCfYUdsIWFfcx/PTKZ9MYwqjkKnpOefyFCc0FVJ3UEkttmoDxyR+NJ5/hl4GkNDASsuPpz/Mk5QVY0esWi82ajQv3Z3yeSkV1JRZjQNnTvBxmfRd8BdbqEUKygP8ft9sMQXHNq7azE+EO6eoeXGm5vr0A148zn3f4MW0V0+ZlFSRfiLILxufjgJkwA+v7zRDAlROsopHzBPyNR04Ffpk7eJemYKiBioHuuT4TFFpKFf7IT6+ZFV5MoWXhyXXvcBvxrPcsVnPpfINk4SCh2MUsOQN4ZIqoQNqKY+HTGjRIa5QS1FQvq8OGZdkfIYH+ACmgDvGtEeIWl7LaQIKQR/n4dIRcgzjWixdAV4jMSSaFhkPy4yPwmupO9beUtzFsDPHxLMjO6qinJufxq1pYhvbKOUp7AbDHIBI5O5fHEkH/06hrl+F/VT9Da/WH8KzCOw9/qE9WsybmUCKzgjyblRhVe/zRag97GhvD7ejPmd21AhO7BAfVTn/X9sxeCMKw3BM/vqRDEkFCEOWBBuLrMoss3ICaCtWOEuEs6YmpYL4Kwht2nOqt2PN4qCcPYKJ+hOGFyfgQDW33CneKxgfHKOhm253ZkdNgAmw8sYiF3crHzcDpFNNOdEtYgQsCF+EV5mrSzH2aua1Qe2rTZZqO0IxdlSBKOyOEdRpjMYmCYxSe+XrDKFQe9FkahjqFL5i+4MUbUfHGMapnWFl7VIaaXUHMoRC7bmnykip8S4Yp0M7grSjRUqom8PDuZBr4jGPvvZIdQd0Bo0XSvao2+o0RpPp0M4AO+o0rzfAqo+TEVE/o8MLy+hHd1fQQHlxXUDyTzxO6ro/6AhtOtAe5D8flNvG6dCB9ZsLr5MO5/XFSGmlDbMTvN5H2+73c0J99FmAie1CASKdSCdg4nKZjnHVlsLLFar6Mq93XM5TYMxUVFyqZfTMCj+9/NUynVT+9pq864MtYVyfpS5gSCOZ1Zsk69d2ne4MbWqZhuk5YtkwCqh+brvkglks1Ut378ozAmnEUEJMwk1yUurq9AOtF/o76YVP/ofe7v5/ev/ySUqk+LCJ10/Vvuzi9Nnuk/Re8iy9P8tLA34PNfSlhBTubS2n7rps+QC5X/04RZVxjZwg3R5pRHgw4bbvtT2Z7bR0ntxr/J7F0sQFjRrznpT5PSTjqmde0y3VO//dBxxPhtBu30DE49GpU6dSZWVl5v21h2+niC87cbi69hq6a+b91DJxIb392a/of//8PEWTepMBovq9Gnm81vHtA28nOKn2bbedpZiMkk1GdQdMzwI7ahrbJbdBYM9PR6QbxDZs+bFzezpsR41qf2HA/MZ8Ev6Ydn7wfXrglytp95mdWWQCkMBYbIA0zVoCv6ix75hwTcZ+AMb1Wbzuuc2MTPF9skDzgfY2fhsyDU5RNFGX6qFoEnhoMzmBtKNqwRnqXiwY81Aibj1LxQmhgYe2GMh81rgCJiS4sUDOPJBpyXvUYB+NBlSvj0YoaC9kG4hHOamQUDndcUr1NF7tym/ftBzTI7EkPJkjHBuwOeiKa6lR5uijAILliRlgFTIlc/YeyUmoUP2UpvNkxiYt6NXkiNTO9BCWGj5VeXOPjKLrg1bE53ZiUWPfKeOKZCCXqkvkrVQ0HzyxU2Oks6dGA40TwfJnOzaV/SGdhqpqP6V6ak4bCAlM8LTVah9I+1AiwR/mUjoxYn3sdGu5tiwys5q4cDKb97fn7Ytnq/TTvP/4JjXgN/tBqP/0H/w8/0hpV0iM10ej0cxbC+qXWpIhfo+rM8iMRvqFrcQjPhinAX6MSDhMc88O0sLzTLy+0ttHUS79g7FBcUyQXTFobi7kEvGaPB1xUE3KZTdV2I56Ny1peJWSnuX85RRspxeEHRXdY6Rkym4yObvZIB6dM5+0unqxOrmsrIy+iH1O73QeobLyMt2uIDHGJXmiN0Dfv/lp6rzyKSUScQqU1dOc2rnU0j+RVh3ppjs/9tEN5710z4c+uraH0cRwWmL7tDhFEjF6sJ1R3aBe7TGii4Y0+RthsVNscGjFrg8v2MpIHLZq4/EpeXWt2nBCaNVmLFzkamOh3XgH0R3rafz48aLoHEmE6Y5DN9G4upFKMSQQZK6evY6+Oe+fqaYs25zgpp3/7jpyAtx0ZHvGPn1wtt07HjMW0kNwQvnspgpHedmu0xd6N83jkso8raRIavhXL4lbo+baINhKWhk88l//HSWTSUEqsqKTF39H3dEu7q2TQpUDvkn0vZt20arZ3xCfm558XcBR1obsZ8rjT5v26et55t/0DWkgmSy5wgmZ4tqoAHRsWFBHMe8rmqHdpZO2ktoTe7jeVdGMGTPEZLKPL39IG498U5zQfXMepK9f+5CpVBoByep68ls597FqDisTluy1rCzIYkOj0+5Sxdk1S9qYoU2EVfdDQG3Dlly2WqSh6D2CBwDVt0OiEecfX5c1Rg7VxtBNtaFXiARI7Nm9LWusjJvtXc0Hj2+iAlF0y+Cz31i0iXnYVuPUcozBoF+JmdcXDu2zEEXG1YsYEk2wioHsbgYSy2fO4TdzZXpw0WTaoWVzWNEy2F5olAslamqd7awkrMxAKSGXDMp/KGCGdAOa58wbKQh7yVXcob00Q0kIlTAzARIgtparoFu9662Qs10xpJIXgezGmHZQUkKBYWlt4y/Xm30OSUWDA0ygcLPnEqbJXDls3d2BW5pDpCW/Uwqp1B2XXEI+YgHZigNeGJOwCiUY6hw7c0KQCGeTe1IGwzDPNgz3kAtwjVAJO8SqQFkQzgVk+yZZ/HOVz7sEacbpMJYQveq4RBLb6xaRIz81SgCxSfK0esmzXqN09wP3waWRpV6lgdSeQmLKgn6RxgAZcpnnbkFuCf9BFR8KD3K/f3Q0SdSfwpcAHevQVSLVmNLYAg+j+SBYLOrlNQ0TskP4k15swUIp0s5hFvZY/YcvI/4CeAZjCToTSnsAAAAASUVORK5CYII='; + const logoImg = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFQAAABUCAYAAAAcaxDBAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABNTSURBVHgBzV0LcFPXmf6PJFt+gkEY8wrYMSEbgst7m02ywZnOZiEJCQlJC+QB25lNs7OzlEJ2ptmZLGayfUy3EEhmW5rM7gCZBtjJgzxmSTvTRSST9IF5pCE0TUosmmBjHIKNZFmWLN2e78hHPvfqXuleSdfONyNLV7q6uve7//uc85vRlwAda25oTFK8lZGn0UPaLI2okUhrTH/KGnU7M+olTevlL0KaeM3e01LaKa/PE2p64dgpGmMwGgN0rGqtS1Ve2cB/fhk/gVbSqI5KAU4wvxlBTdNe9VJ5sOnAb0I0yhg1QiWJTGN3E0gcHQRTpO0dTXJdJ7RjzZJWflHrGaNVdiTRN2kalTfOIU9VLfnqp5ruM9TTxR+dlIqGKX7uI7IDLrl7PFS2zW1iXSMURGqkbaUc0uiprqWqxa1UOXcxVcxdxAmcRoUApMZDH9HAmeMU+8NxQbYV3Ca25ITCwaRY4immcYk0AUgcv3wtJ3CxeLgBEBw++jpF249akusWsSUltGPNoq0aY5vMVLviusU04b5HbJMoVLo/ItRaBUyBp7rGtjTHuNSGj75BkbdeN/2ckdbWdODENioRSkIopFLThl4hpi0wflZzy0pO5D9aEiDsIFfXQagtf4CAXCqronzWHHFc3CQ/f53rZuGYl198zorYEKOyW0shrUUT2rFu8bc1jdqMUplLIkFi9NhRCvOLA4mp/jCVAjAn+N2qJa1UvXSZkGYjQOylfTu4OQjqPxAhl7atef+JnVQEiiK0Y+2ipzSNq7gCXFT9o1vFRRkB6evnFxJ5642SkWgF4fD4OUxYba4dEW4GLr/0bJY2FGsCCiIUMaVWEX6FDB4cF1D/T1uzJANE4uTxPBaoWbbSlNgcZiDIYsl7mg6d6iWHcEyolb0MPLyFxq1Yq9sXqg31ihx9nb4MsCK298VnxQ3XQaNTjJXd49SuOiJUkEmJIyRy7TSgWg2bf5xlK/sO76defpJuq7ZTgMy61Y9Q7bI7de/Dlndvf8xoAhw7K9uECjX3R46okomTm/rEbt0dh1TixIzqDeI9lSPZD/ZDWDT0uT2PXmqYSSvI7HryUT2pkNTB5K121d82oZ+sWQzJbJXbZmRa3GWBces2UuXX7qOKigryeDy6z0A+wqbosaDIdEYLZtdgSiq3qVcfOH6rnWPaIlQE7MTacp1ImHvuL/Ztz63iE+qpZtN2qp8z13IX6Siix4OjYi7gQCdy+6+aADNSecKys3l/+3fyHc+bb4d0nMl+KLfNyIS9vPTfPyAtEbc8jvjevz5F45r/inIBpqF6aSvV/M1twiTYLX4UCpwzYlIRw17TMnIOS5aJ8E5eE5e8Gza2TO17+nTXb3IdLyehaSeUOsBfVsj3pv77z6hsWmNmH5AJycwFQeb3nqfBqvHU399P4XBYPMfjcWK8DOXz+bK+I4mFCo2GGRh479dZpFbMbhGkSvBzvWHTvFkHd53+zNKe5lR5bjc7SPHoE7h3rOPZjwTU/POftlE+4ORS5ZVEly+OvDm1UTw0bldRsmtoaCC/32/6/SvQgDw3rVSY9GibTv2zfps7qasPHl9o9X1LCYXd5HxnKkbIyQPrt2Q+h325uOOxnGqeOQfsE+vXvxnhN7krROzd/6PUlJkU9nOJrK4mrzf7lPxcaiCt0IxE57msgkkpAQdZNf9G8tYFMr8Ns5PoDKV3YDRl47zp7OnTnUGz75tK6HC82SG3jXbTwhM6Q0U1sZvvFERVz77e1PtbwSptLBVwndN/+PNMxocb+OnGu0acJM/7mVa20Cw+Nb2CFCW2qtsIhFUndPml5wq/mAmTiT2yjep2HKKZ/7CF6r+ylKqqqmyTCdRwlcQNRmXfDeDaEP5JgFjUJzLghSDUfM2+m3UVkE4uthvkNvJz1aZAOgpNJbWv3U/jnnyeZi5bQRMmTHBEohFprfmZa6RC9eFwJcCDmg2igI5RCeP3sq7IKJ2BhzdnXosY0Zjz2gHUm0vltAe/TYFAoCgiVUByQGqhQyf5gBxftddwyiqGh3j056RuGKUTjqhoVR8mc8bf/r2wk6VGmtTdIpIoNWRxRwISCk4UtBqlVEeoUTpRaZcAkYWoOtQ8MG+xaaxZKuCmj1u+ltwArlmtS6icABjRVbczhNqRTqfQFvGM57avU21t6aXnvTOd9PKb79O+l9rpnfYOGn/7WlekFFDNnBxykcDweMeqBZnRigyhmAqjHsSY2xbkiLh0Tpw4MbMZiQ5yAo7T1h2/oG89/iL9aHeQLvQ4jynfaQ8JEqsry6lhUi2dPXeJdr/4vmtSCgnVSalqS+HxK30b5GZGD73E1mvyTcNdKEg6m3hsOeWqjKqDuMf+43VOQA09vHoJNTcGqKbKL0h2ipuWNIqHEaloC115c78rRRUM3UhO8Cyyv+HfYZqG2TBiLEpIaDqQHynNVfHCwMhJhrMHtOzguqUi85GAet52y7W0/Ym7aP7caYJMQD6XAnBQmDjhBhAuqh7foA2tUu0FoVnqrngyjE4WdMeb5upy83uXt3DJdGdigwpjJb5UAJn9nAuJSsMIhVR7QejwBC4BqLsaLPcXIp0Az7vLy8szm1Pq3XEYRoh5US45J3UwT6q9BFf7VjynCfWMqDvGtVUUVDrjhWRx8BIF8FaQTk46OGxD7TEBwg1gQoaq9jrzwkjYSU/H/UsXqJMUVGcEz1aIumt1k/OSibDnP3cfoZ/se7cgTw/8ZN+vRdjUzb+/ekUL/fJouhjtFqFylouETu05h/BFnqQv1ah+ya+czKBL1XKQsIV7/F+89VFGygrx9t09V8RzJBrnEnpEhFOAf9a15BZUTjBjUEWSkq0ebj914+uq/SxmYkIqlbL87J3joczrmqp0Ovpue4icAtGCBGJRue1WwQRQJdRYQ2CkNfpI0+bLqqhRVYod4gWpZqof6R8pSr/85u/F880mcWU+IJ6Fs4NkNs8KZKIIT1UNuQWjTwGpsr6B9QE+D6M6GdAbp9Cod8MJWO9FzL+0JHT1innC/kmAlBsLIBRAbIuHCjte3sMVo2o2FyLuP+N8ZCbyAdmCsTgEIZTv8ZHhRp8mVlukRdQ4Pl0wBqLiCYNwZkWRe5d/RQT0cEwNnMx7V7RQKWE26068P0xi7fXc/l2l/8wuoQC4kVzpfwsqz1gdDYuoOqc9FY1QwcD4USxKiUTCchczySoVZGjjG8clqIGTN4M7qsnZJErEPiVHwPA2pSPDrHUAPquFBEXnw5zUoaEhKhpJfh69PEMZ5BoT78q/L394+H6z/oVLj42sNsWDi543yRFyDBI2ulek5KOEA5OnU8EY4Pb7Uz58Gy4s0rBLZtdBrsJ9VDK4R+jlnsIl9NIbRKE2chNQc0hmKckE3CP0Qkh4eTgmNafPi3ina2RCIsOnecHnT87tpl1wQrVQ1npKoqILDKzjA+HrBgYGnBHamb/2CmLiF7Pf940f/jyW3gfSl+DJ1BB/xP6cfi4FrKIIjNfrJBQr1Ea+VGRwzFUenn5w0OFxon/M+XHPYWchjhvAsh4JlTMuQb08rmchua16r5IMzXZ1UCwWc/adpHW4BiLHmkxAF6/rskkW8nC1PCc3jVMHiya185xwTI6cU611ETrp8N64AWN6rg+htD5O6IiEGrMjY23UMTrOiCfYUdsIWFfcx/PTKZ9MYwqjkKnpOefyFCc0FVJ3UEkttmoDxyR+NJ5/hl4GkNDASsuPpz/Mk5QVY0esWi82ajQv3Z3yeSkV1JRZjQNnTvBxmfRd8BdbqEUKygP8ft9sMQXHNq7azE+EO6eoeXGm5vr0A148zn3f4MW0V0+ZlFSRfiLILxufjgJkwA+v7zRDAlROsopHzBPyNR04Ffpk7eJemYKiBioHuuT4TFFpKFf7IT6+ZFV5MoWXhyXXvcBvxrPcsVnPpfINk4SCh2MUsOQN4ZIqoQNqKY+HTGjRIa5QS1FQvq8OGZdkfIYH+ACmgDvGtEeIWl7LaQIKQR/n4dIRcgzjWixdAV4jMSSaFhkPy4yPwmupO9beUtzFsDPHxLMjO6qinJufxq1pYhvbKOUp7AbDHIBI5O5fHEkH/06hrl+F/VT9Da/WH8KzCOw9/qE9WsybmUCKzgjyblRhVe/zRag97GhvD7ejPmd21AhO7BAfVTn/X9sxeCMKw3BM/vqRDEkFCEOWBBuLrMoss3ICaCtWOEuEs6YmpYL4Kwht2nOqt2PN4qCcPYKJ+hOGFyfgQDW33CneKxgfHKOhm253ZkdNgAmw8sYiF3crHzcDpFNNOdEtYgQsCF+EV5mrSzH2aua1Qe2rTZZqO0IxdlSBKOyOEdRpjMYmCYxSe+XrDKFQe9FkahjqFL5i+4MUbUfHGMapnWFl7VIaaXUHMoRC7bmnykip8S4Yp0M7grSjRUqom8PDuZBr4jGPvvZIdQd0Bo0XSvao2+o0RpPp0M4AO+o0rzfAqo+TEVE/o8MLy+hHd1fQQHlxXUDyTzxO6ro/6AhtOtAe5D8flNvG6dCB9ZsLr5MO5/XFSGmlDbMTvN5H2+73c0J99FmAie1CASKdSCdg4nKZjnHVlsLLFar6Mq93XM5TYMxUVFyqZfTMCj+9/NUynVT+9pq864MtYVyfpS5gSCOZ1Zsk69d2ne4MbWqZhuk5YtkwCqh+brvkglks1Ut378ozAmnEUEJMwk1yUurq9AOtF/o76YVP/ofe7v5/ev/ySUqk+LCJ10/Vvuzi9Nnuk/Re8iy9P8tLA34PNfSlhBTubS2n7rps+QC5X/04RZVxjZwg3R5pRHgw4bbvtT2Z7bR0ntxr/J7F0sQFjRrznpT5PSTjqmde0y3VO//dBxxPhtBu30DE49GpU6dSZWVl5v21h2+niC87cbi69hq6a+b91DJxIb392a/of//8PEWTepMBovq9Gnm81vHtA28nOKn2bbedpZiMkk1GdQdMzwI7ahrbJbdBYM9PR6QbxDZs+bFzezpsR41qf2HA/MZ8Ev6Ydn7wfXrglytp95mdWWQCkMBYbIA0zVoCv6ix75hwTcZ+AMb1Wbzuuc2MTPF9skDzgfY2fhsyDU5RNFGX6qFoEnhoMzmBtKNqwRnqXiwY81Aibj1LxQmhgYe2GMh81rgCJiS4sUDOPJBpyXvUYB+NBlSvj0YoaC9kG4hHOamQUDndcUr1NF7tym/ftBzTI7EkPJkjHBuwOeiKa6lR5uijAILliRlgFTIlc/YeyUmoUP2UpvNkxiYt6NXkiNTO9BCWGj5VeXOPjKLrg1bE53ZiUWPfKeOKZCCXqkvkrVQ0HzyxU2Oks6dGA40TwfJnOzaV/SGdhqpqP6V6ak4bCAlM8LTVah9I+1AiwR/mUjoxYn3sdGu5tiwys5q4cDKb97fn7Ytnq/TTvP/4JjXgN/tBqP/0H/w8/0hpV0iM10ej0cxbC+qXWpIhfo+rM8iMRvqFrcQjPhinAX6MSDhMc88O0sLzTLy+0ttHUS79g7FBcUyQXTFobi7kEvGaPB1xUE3KZTdV2I56Ny1peJWSnuX85RRspxeEHRXdY6Rkym4yObvZIB6dM5+0unqxOrmsrIy+iH1O73QeobLyMt2uIDHGJXmiN0Dfv/lp6rzyKSUScQqU1dOc2rnU0j+RVh3ppjs/9tEN5710z4c+uraH0cRwWmL7tDhFEjF6sJ1R3aBe7TGii4Y0+RthsVNscGjFrg8v2MpIHLZq4/EpeXWt2nBCaNVmLFzkamOh3XgH0R3rafz48aLoHEmE6Y5DN9G4upFKMSQQZK6evY6+Oe+fqaYs25zgpp3/7jpyAtx0ZHvGPn1wtt07HjMW0kNwQvnspgpHedmu0xd6N83jkso8raRIavhXL4lbo+baINhKWhk88l//HSWTSUEqsqKTF39H3dEu7q2TQpUDvkn0vZt20arZ3xCfm558XcBR1obsZ8rjT5v26et55t/0DWkgmSy5wgmZ4tqoAHRsWFBHMe8rmqHdpZO2ktoTe7jeVdGMGTPEZLKPL39IG498U5zQfXMepK9f+5CpVBoByep68ls597FqDisTluy1rCzIYkOj0+5Sxdk1S9qYoU2EVfdDQG3Dlly2WqSh6D2CBwDVt0OiEecfX5c1Rg7VxtBNtaFXiARI7Nm9LWusjJvtXc0Hj2+iAlF0y+Cz31i0iXnYVuPUcozBoF+JmdcXDu2zEEXG1YsYEk2wioHsbgYSy2fO4TdzZXpw0WTaoWVzWNEy2F5olAslamqd7awkrMxAKSGXDMp/KGCGdAOa58wbKQh7yVXcob00Q0kIlTAzARIgtparoFu9662Qs10xpJIXgezGmHZQUkKBYWlt4y/Xm30OSUWDA0ygcLPnEqbJXDls3d2BW5pDpCW/Uwqp1B2XXEI+YgHZigNeGJOwCiUY6hw7c0KQCGeTe1IGwzDPNgz3kAtwjVAJO8SqQFkQzgVk+yZZ/HOVz7sEacbpMJYQveq4RBLb6xaRIz81SgCxSfK0esmzXqN09wP3waWRpV6lgdSeQmLKgn6RxgAZcpnnbkFuCf9BFR8KD3K/f3Q0SdSfwpcAHevQVSLVmNLYAg+j+SBYLOrlNQ0TskP4k15swUIp0s5hFvZY/YcvI/4CeAZjCToTSnsAAAAASUVORK5CYII='; const loadingImages = { - darkMode: 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', - lightMode: 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E' // 'data:application/octet-stream;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCTxzdHlsZT4KCQlAa2V5ZnJhbWVzIHJvdGF0ZSB7CgkJCWZyb20gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMGRlZyk7CgkJCX0KCQkJdG8gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMzU5ZGVnKTsKCQkJfQoJCX0KCTwvc3R5bGU+Cgk8ZyBzdHlsZT0idHJhbnNmb3JtLW9yaWdpbjogNTAlIDUwJTsgYW5pbWF0aW9uOiByb3RhdGUgMXMgaW5maW5pdGUgcmV2ZXJzZSBsaW5lYXI7Ij4KCQk8cmVjdCB4PSIxOC4wOTY4IiB5PSIxNi4wODYxIiB3aWR0aD0iMyIgaGVpZ2h0PSI3IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSgxMzYuMTYxIDE4LjA5NjggMTYuMDg2MSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CQoJCTxyZWN0IHg9IjguNDk4NzgiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC40Ii8+CgkJPHJlY3QgeD0iMTkuOTk3NiIgeT0iOC4zNzQ1MSIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgMTkuOTk3NiA4LjM3NDUxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz4KCQk8cmVjdCB4PSIxNi4xNzI3IiB5PSIxLjk5MTciIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgMTYuMTcyNyAxLjk5MTcpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuMyIvPgoJCTxyZWN0IHg9IjguOTEzMDkiIHk9IjYuODg1MDEiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDEzNi4xNjEgOC45MTMwOSA2Ljg4NTAxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjYiLz4KCQk8cmVjdCB4PSI2Ljc5NjAyIiB5PSIxMC45OTYiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgNi43OTYwMiAxMC45OTYpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuNyIvPgoJCTxyZWN0IHg9IjciIHk9IjguNjI1NDkiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDkwIDcgOC42MjU0OSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC44Ii8+CQkKCQk8cmVjdCB4PSI4LjQ5ODc4IiB5PSIxMyIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjkiLz4KCTwvZz4KPC9zdmc+Cg==' + darkMode: + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', + lightMode: + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', // 'data:application/octet-stream;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCTxzdHlsZT4KCQlAa2V5ZnJhbWVzIHJvdGF0ZSB7CgkJCWZyb20gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMGRlZyk7CgkJCX0KCQkJdG8gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMzU5ZGVnKTsKCQkJfQoJCX0KCTwvc3R5bGU+Cgk8ZyBzdHlsZT0idHJhbnNmb3JtLW9yaWdpbjogNTAlIDUwJTsgYW5pbWF0aW9uOiByb3RhdGUgMXMgaW5maW5pdGUgcmV2ZXJzZSBsaW5lYXI7Ij4KCQk8cmVjdCB4PSIxOC4wOTY4IiB5PSIxNi4wODYxIiB3aWR0aD0iMyIgaGVpZ2h0PSI3IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSgxMzYuMTYxIDE4LjA5NjggMTYuMDg2MSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CQoJCTxyZWN0IHg9IjguNDk4NzgiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC40Ii8+CgkJPHJlY3QgeD0iMTkuOTk3NiIgeT0iOC4zNzQ1MSIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgMTkuOTk3NiA4LjM3NDUxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz4KCQk8cmVjdCB4PSIxNi4xNzI3IiB5PSIxLjk5MTciIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgMTYuMTcyNyAxLjk5MTcpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuMyIvPgoJCTxyZWN0IHg9IjguOTEzMDkiIHk9IjYuODg1MDEiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDEzNi4xNjEgOC45MTMwOSA2Ljg4NTAxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjYiLz4KCQk8cmVjdCB4PSI2Ljc5NjAyIiB5PSIxMC45OTYiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgNi43OTYwMiAxMC45OTYpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuNyIvPgoJCTxyZWN0IHg9IjciIHk9IjguNjI1NDkiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDkwIDcgOC42MjU0OSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC44Ii8+CQkKCQk8cmVjdCB4PSI4LjQ5ODc4IiB5PSIxMyIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjkiLz4KCTwvZz4KPC9zdmc+Cg==' }; - const closeIcon = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5.99998%204.58578L10.2426%200.34314C10.6331%20-0.0473839%2011.2663%20-0.0473839%2011.6568%200.34314C12.0474%200.733665%2012.0474%201.36683%2011.6568%201.75735L7.41419%205.99999L11.6568%2010.2426C12.0474%2010.6332%2012.0474%2011.2663%2011.6568%2011.6568C11.2663%2012.0474%2010.6331%2012.0474%2010.2426%2011.6568L5.99998%207.41421L1.75734%2011.6568C1.36681%2012.0474%200.733649%2012.0474%200.343125%2011.6568C-0.0473991%2011.2663%20-0.0473991%2010.6332%200.343125%2010.2426L4.58577%205.99999L0.343125%201.75735C-0.0473991%201.36683%20-0.0473991%200.733665%200.343125%200.34314C0.733649%20-0.0473839%201.36681%20-0.0473839%201.75734%200.34314L5.99998%204.58578Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E'; + const closeIcon = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5.99998%204.58578L10.2426%200.34314C10.6331%20-0.0473839%2011.2663%20-0.0473839%2011.6568%200.34314C12.0474%200.733665%2012.0474%201.36683%2011.6568%201.75735L7.41419%205.99999L11.6568%2010.2426C12.0474%2010.6332%2012.0474%2011.2663%2011.6568%2011.6568C11.2663%2012.0474%2010.6331%2012.0474%2010.2426%2011.6568L5.99998%207.41421L1.75734%2011.6568C1.36681%2012.0474%200.733649%2012.0474%200.343125%2011.6568C-0.0473991%2011.2663%20-0.0473991%2010.6332%200.343125%2010.2426L4.58577%205.99999L0.343125%201.75735C-0.0473991%201.36683%20-0.0473991%200.733665%200.343125%200.34314C0.733649%20-0.0473839%201.36681%20-0.0473839%201.75734%200.34314L5.99998%204.58578Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E'; - const blockedFBLogo = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2280%22%20height%3D%2280%22%20viewBox%3D%220%200%2080%2080%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Ccircle%20cx%3D%2240%22%20cy%3D%2240%22%20r%3D%2240%22%20fill%3D%22white%22%2F%3E%0A%3Cg%20clip-path%3D%22url%28%23clip0%29%22%3E%0A%3Cpath%20d%3D%22M73.8457%2039.974C73.8457%2021.284%2058.7158%206.15405%2040.0258%206.15405C21.3358%206.15405%206.15344%2021.284%206.15344%2039.974C6.15344%2056.884%2018.5611%2070.8622%2034.7381%2073.4275V49.764H26.0999V39.974H34.7381V32.5399C34.7381%2024.0587%2039.764%2019.347%2047.5122%2019.347C51.2293%2019.347%2055.0511%2020.0799%2055.0511%2020.0799V28.3517H50.8105C46.6222%2028.3517%2045.2611%2030.9693%2045.2611%2033.6393V39.974H54.6846L53.1664%2049.764H45.2611V73.4275C61.4381%2070.9146%2073.8457%2056.884%2073.8457%2039.974Z%22%20fill%3D%22%231877F2%22%2F%3E%0A%3C%2Fg%3E%0A%3Crect%20x%3D%223.01295%22%20y%3D%2211.7158%22%20width%3D%2212.3077%22%20height%3D%2292.3077%22%20rx%3D%226.15385%22%20transform%3D%22rotate%28-45%203.01295%2011.7158%29%22%20fill%3D%22%23666666%22%20stroke%3D%22white%22%20stroke-width%3D%226.15385%22%2F%3E%0A%3Cdefs%3E%0A%3CclipPath%20id%3D%22clip0%22%3E%0A%3Crect%20width%3D%2267.6923%22%20height%3D%2267.6923%22%20fill%3D%22white%22%20transform%3D%22translate%286.15344%206.15405%29%22%2F%3E%0A%3C%2FclipPath%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E'; - const facebookLogo = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMSAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguODUgMTkuOUM0LjEgMTkuMDUgMC41IDE0Ljk1IDAuNSAxMEMwLjUgNC41IDUgMCAxMC41IDBDMTYgMCAyMC41IDQuNSAyMC41IDEwQzIwLjUgMTQuOTUgMTYuOSAxOS4wNSAxMi4xNSAxOS45TDExLjYgMTkuNDVIOS40TDguODUgMTkuOVoiIGZpbGw9IiMxODc3RjIiLz4KPHBhdGggZD0iTTE0LjQgMTIuOEwxNC44NSAxMEgxMi4yVjguMDVDMTIuMiA3LjI1IDEyLjUgNi42NSAxMy43IDYuNjVIMTVWNC4xQzE0LjMgNCAxMy41IDMuOSAxMi44IDMuOUMxMC41IDMuOSA4LjkgNS4zIDguOSA3LjhWMTBINi40VjEyLjhIOC45VjE5Ljg1QzkuNDUgMTkuOTUgMTAgMjAgMTAuNTUgMjBDMTEuMSAyMCAxMS42NSAxOS45NSAxMi4yIDE5Ljg1VjEyLjhIMTQuNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='; + const blockedFBLogo = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2280%22%20height%3D%2280%22%20viewBox%3D%220%200%2080%2080%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Ccircle%20cx%3D%2240%22%20cy%3D%2240%22%20r%3D%2240%22%20fill%3D%22white%22%2F%3E%0A%3Cg%20clip-path%3D%22url%28%23clip0%29%22%3E%0A%3Cpath%20d%3D%22M73.8457%2039.974C73.8457%2021.284%2058.7158%206.15405%2040.0258%206.15405C21.3358%206.15405%206.15344%2021.284%206.15344%2039.974C6.15344%2056.884%2018.5611%2070.8622%2034.7381%2073.4275V49.764H26.0999V39.974H34.7381V32.5399C34.7381%2024.0587%2039.764%2019.347%2047.5122%2019.347C51.2293%2019.347%2055.0511%2020.0799%2055.0511%2020.0799V28.3517H50.8105C46.6222%2028.3517%2045.2611%2030.9693%2045.2611%2033.6393V39.974H54.6846L53.1664%2049.764H45.2611V73.4275C61.4381%2070.9146%2073.8457%2056.884%2073.8457%2039.974Z%22%20fill%3D%22%231877F2%22%2F%3E%0A%3C%2Fg%3E%0A%3Crect%20x%3D%223.01295%22%20y%3D%2211.7158%22%20width%3D%2212.3077%22%20height%3D%2292.3077%22%20rx%3D%226.15385%22%20transform%3D%22rotate%28-45%203.01295%2011.7158%29%22%20fill%3D%22%23666666%22%20stroke%3D%22white%22%20stroke-width%3D%226.15385%22%2F%3E%0A%3Cdefs%3E%0A%3CclipPath%20id%3D%22clip0%22%3E%0A%3Crect%20width%3D%2267.6923%22%20height%3D%2267.6923%22%20fill%3D%22white%22%20transform%3D%22translate%286.15344%206.15405%29%22%2F%3E%0A%3C%2FclipPath%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E'; + const facebookLogo = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMSAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguODUgMTkuOUM0LjEgMTkuMDUgMC41IDE0Ljk1IDAuNSAxMEMwLjUgNC41IDUgMCAxMC41IDBDMTYgMCAyMC41IDQuNSAyMC41IDEwQzIwLjUgMTQuOTUgMTYuOSAxOS4wNSAxMi4xNSAxOS45TDExLjYgMTkuNDVIOS40TDguODUgMTkuOVoiIGZpbGw9IiMxODc3RjIiLz4KPHBhdGggZD0iTTE0LjQgMTIuOEwxNC44NSAxMEgxMi4yVjguMDVDMTIuMiA3LjI1IDEyLjUgNi42NSAxMy43IDYuNjVIMTVWNC4xQzE0LjMgNCAxMy41IDMuOSAxMi44IDMuOUMxMC41IDMuOSA4LjkgNS4zIDguOSA3LjhWMTBINi40VjEyLjhIOC45VjE5Ljg1QzkuNDUgMTkuOTUgMTAgMjAgMTAuNTUgMjBDMTEuMSAyMCAxMS42NSAxOS45NSAxMi4yIDE5Ljg1VjEyLjhIMTQuNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='; - const blockedYTVideo = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2275%22%20height%3D%2275%22%20viewBox%3D%220%200%2075%2075%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Crect%20x%3D%226.75%22%20y%3D%2215.75%22%20width%3D%2256.25%22%20height%3D%2239%22%20rx%3D%2213.5%22%20fill%3D%22%23DE5833%22%2F%3E%0A%20%20%3Cmask%20id%3D%22path-2-outside-1_885_11045%22%20maskUnits%3D%22userSpaceOnUse%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%20fill%3D%22black%22%3E%0A%20%20%3Crect%20fill%3D%22white%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%2F%3E%0A%20%20%3C%2Fmask%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%20fill%3D%22white%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M30.0296%2044.6809L31.5739%2047.2529L30.0296%2044.6809ZM30.0296%2025.8024L31.5739%2023.2304L30.0296%2025.8024ZM42.8944%2036.9563L44.4387%2039.5283L42.8944%2036.9563ZM41.35%2036.099L28.4852%2028.3744L31.5739%2023.2304L44.4387%2030.955L41.35%2036.099ZM30%2027.5171L30%2042.9663L24%2042.9663L24%2027.5171L30%2027.5171ZM28.4852%2042.1089L41.35%2034.3843L44.4387%2039.5283L31.5739%2047.2529L28.4852%2042.1089ZM30%2042.9663C30%2042.1888%2029.1517%2041.7087%2028.4852%2042.1089L31.5739%2047.2529C28.2413%2049.2539%2024%2046.8535%2024%2042.9663L30%2042.9663ZM28.4852%2028.3744C29.1517%2028.7746%2030%2028.2945%2030%2027.5171L24%2027.5171C24%2023.6299%2028.2413%2021.2294%2031.5739%2023.2304L28.4852%2028.3744ZM44.4387%2030.955C47.6735%2032.8974%2047.6735%2037.586%2044.4387%2039.5283L41.35%2034.3843C40.7031%2034.7728%2040.7031%2035.7105%2041.35%2036.099L44.4387%2030.955Z%22%20fill%3D%22%23BC4726%22%20mask%3D%22url(%23path-2-outside-1_885_11045)%22%2F%3E%0A%20%20%3Ccircle%20cx%3D%2257.75%22%20cy%3D%2252.5%22%20r%3D%2213.5%22%20fill%3D%22%23E0E0E0%22%2F%3E%0A%20%20%3Crect%20x%3D%2248.75%22%20y%3D%2250.25%22%20width%3D%2218%22%20height%3D%224.5%22%20rx%3D%221.5%22%20fill%3D%22%23666666%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57.9853%2015.8781C58.2046%2016.1015%2058.5052%2016.2262%2058.8181%2016.2238C59.1311%2016.2262%2059.4316%2016.1015%2059.6509%2015.8781L62.9821%2012.5469C63.2974%2012.2532%2063.4272%2011.8107%2063.3206%2011.3931C63.2139%2010.9756%2062.8879%2010.6495%2062.4703%2010.5429C62.0528%2010.4363%2061.6103%2010.5661%2061.3165%2010.8813L57.9853%2014.2125C57.7627%2014.4325%2057.6374%2014.7324%2057.6374%2015.0453C57.6374%2015.3583%2057.7627%2015.6582%2057.9853%2015.8781ZM61.3598%2018.8363C61.388%2019.4872%2061.9385%2019.9919%2062.5893%2019.9637L62.6915%2019.9559L66.7769%2019.6023C67.4278%2019.5459%2067.9097%2018.9726%2067.8533%2018.3217C67.7968%2017.6708%2067.2235%2017.1889%2066.5726%2017.2453L62.4872%2017.6067C61.8363%2017.6349%2061.3316%2018.1854%2061.3598%2018.8363Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.6535%2015.8781C10.4342%2016.1015%2010.1336%2016.2262%209.82067%2016.2238C9.5077%2016.2262%209.20717%2016.1015%208.98787%2015.8781L5.65667%2012.5469C5.34138%2012.2532%205.2116%2011.8107%205.31823%2011.3931C5.42487%2010.9756%205.75092%2010.6495%206.16847%2010.5429C6.58602%2010.4363%207.02848%2010.5661%207.32227%2010.8813L10.6535%2014.2125C10.8761%2014.4325%2011.0014%2014.7324%2011.0014%2015.0453C11.0014%2015.3583%2010.8761%2015.6582%2010.6535%2015.8781ZM7.2791%2018.8362C7.25089%2019.4871%206.7004%2019.9919%206.04954%2019.9637L5.9474%2019.9558L1.86197%2019.6023C1.44093%2019.5658%201.07135%2019.3074%200.892432%2018.9246C0.713515%2018.5417%200.752449%2018.0924%200.994567%2017.7461C1.23669%2017.3997%201.6452%2017.2088%202.06624%2017.2453L6.15167%2017.6067C6.80254%2017.6349%207.3073%2018.1854%207.2791%2018.8362Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%3C%2Fsvg%3E%0A'; - const videoPlayDark = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E%0A'; - const videoPlayLight = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23FFFFFF%22%2F%3E%0A%3C%2Fsvg%3E'; + const blockedYTVideo = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2275%22%20height%3D%2275%22%20viewBox%3D%220%200%2075%2075%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Crect%20x%3D%226.75%22%20y%3D%2215.75%22%20width%3D%2256.25%22%20height%3D%2239%22%20rx%3D%2213.5%22%20fill%3D%22%23DE5833%22%2F%3E%0A%20%20%3Cmask%20id%3D%22path-2-outside-1_885_11045%22%20maskUnits%3D%22userSpaceOnUse%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%20fill%3D%22black%22%3E%0A%20%20%3Crect%20fill%3D%22white%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%2F%3E%0A%20%20%3C%2Fmask%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%20fill%3D%22white%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M30.0296%2044.6809L31.5739%2047.2529L30.0296%2044.6809ZM30.0296%2025.8024L31.5739%2023.2304L30.0296%2025.8024ZM42.8944%2036.9563L44.4387%2039.5283L42.8944%2036.9563ZM41.35%2036.099L28.4852%2028.3744L31.5739%2023.2304L44.4387%2030.955L41.35%2036.099ZM30%2027.5171L30%2042.9663L24%2042.9663L24%2027.5171L30%2027.5171ZM28.4852%2042.1089L41.35%2034.3843L44.4387%2039.5283L31.5739%2047.2529L28.4852%2042.1089ZM30%2042.9663C30%2042.1888%2029.1517%2041.7087%2028.4852%2042.1089L31.5739%2047.2529C28.2413%2049.2539%2024%2046.8535%2024%2042.9663L30%2042.9663ZM28.4852%2028.3744C29.1517%2028.7746%2030%2028.2945%2030%2027.5171L24%2027.5171C24%2023.6299%2028.2413%2021.2294%2031.5739%2023.2304L28.4852%2028.3744ZM44.4387%2030.955C47.6735%2032.8974%2047.6735%2037.586%2044.4387%2039.5283L41.35%2034.3843C40.7031%2034.7728%2040.7031%2035.7105%2041.35%2036.099L44.4387%2030.955Z%22%20fill%3D%22%23BC4726%22%20mask%3D%22url(%23path-2-outside-1_885_11045)%22%2F%3E%0A%20%20%3Ccircle%20cx%3D%2257.75%22%20cy%3D%2252.5%22%20r%3D%2213.5%22%20fill%3D%22%23E0E0E0%22%2F%3E%0A%20%20%3Crect%20x%3D%2248.75%22%20y%3D%2250.25%22%20width%3D%2218%22%20height%3D%224.5%22%20rx%3D%221.5%22%20fill%3D%22%23666666%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57.9853%2015.8781C58.2046%2016.1015%2058.5052%2016.2262%2058.8181%2016.2238C59.1311%2016.2262%2059.4316%2016.1015%2059.6509%2015.8781L62.9821%2012.5469C63.2974%2012.2532%2063.4272%2011.8107%2063.3206%2011.3931C63.2139%2010.9756%2062.8879%2010.6495%2062.4703%2010.5429C62.0528%2010.4363%2061.6103%2010.5661%2061.3165%2010.8813L57.9853%2014.2125C57.7627%2014.4325%2057.6374%2014.7324%2057.6374%2015.0453C57.6374%2015.3583%2057.7627%2015.6582%2057.9853%2015.8781ZM61.3598%2018.8363C61.388%2019.4872%2061.9385%2019.9919%2062.5893%2019.9637L62.6915%2019.9559L66.7769%2019.6023C67.4278%2019.5459%2067.9097%2018.9726%2067.8533%2018.3217C67.7968%2017.6708%2067.2235%2017.1889%2066.5726%2017.2453L62.4872%2017.6067C61.8363%2017.6349%2061.3316%2018.1854%2061.3598%2018.8363Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.6535%2015.8781C10.4342%2016.1015%2010.1336%2016.2262%209.82067%2016.2238C9.5077%2016.2262%209.20717%2016.1015%208.98787%2015.8781L5.65667%2012.5469C5.34138%2012.2532%205.2116%2011.8107%205.31823%2011.3931C5.42487%2010.9756%205.75092%2010.6495%206.16847%2010.5429C6.58602%2010.4363%207.02848%2010.5661%207.32227%2010.8813L10.6535%2014.2125C10.8761%2014.4325%2011.0014%2014.7324%2011.0014%2015.0453C11.0014%2015.3583%2010.8761%2015.6582%2010.6535%2015.8781ZM7.2791%2018.8362C7.25089%2019.4871%206.7004%2019.9919%206.04954%2019.9637L5.9474%2019.9558L1.86197%2019.6023C1.44093%2019.5658%201.07135%2019.3074%200.892432%2018.9246C0.713515%2018.5417%200.752449%2018.0924%200.994567%2017.7461C1.23669%2017.3997%201.6452%2017.2088%202.06624%2017.2453L6.15167%2017.6067C6.80254%2017.6349%207.3073%2018.1854%207.2791%2018.8362Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%3C%2Fsvg%3E%0A'; + const videoPlayDark = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E%0A'; + const videoPlayLight = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23FFFFFF%22%2F%3E%0A%3C%2Fsvg%3E'; var localesJSON = `{"bg":{"facebook.json":{"informationalModalMessageTitle":"При влизане разрешавате на Facebook да Ви проследява","informationalModalMessageBody":"След като влезете, DuckDuckGo не може да блокира проследяването от Facebook в съдържанието на този сайт.","informationalModalConfirmButtonText":"Вход","informationalModalRejectButtonText":"Назад","loginButtonText":"Вход във Facebook","loginBodyText":"Facebook проследява Вашата активност в съответния сайт, когато го използвате за вход.","buttonTextUnblockContent":"Разблокиране на съдържание от Facebook","buttonTextUnblockComment":"Разблокиране на коментар във Facebook","buttonTextUnblockComments":"Разблокиране на коментари във Facebook","buttonTextUnblockPost":"Разблокиране на публикация от Facebook","buttonTextUnblockVideo":"Разблокиране на видео от Facebook","buttonTextUnblockLogin":"Разблокиране на вход с Facebook","infoTitleUnblockContent":"DuckDuckGo блокира това съдържание, за да предотврати проследяване от Facebook","infoTitleUnblockComment":"DuckDuckGo блокира този коментар, за да предотврати проследяване от Facebook","infoTitleUnblockComments":"DuckDuckGo блокира тези коментари, за да предотврати проследяване от Facebook","infoTitleUnblockPost":"DuckDuckGo блокира тази публикация, за да предотврати проследяване от Facebook","infoTitleUnblockVideo":"DuckDuckGo блокира това видео, за да предотврати проследяване от Facebook","infoTextUnblockContent":"Блокирахме проследяването от Facebook при зареждане на страницата. Ако разблокирате това съдържание, Facebook ще следи Вашата активност."},"shared.json":{"learnMore":"Научете повече","readAbout":"Прочетете за тази защита на поверителността","shareFeedback":"Споделяне на отзив"},"youtube.json":{"informationalModalMessageTitle":"Активиране на всички прегледи в YouTube?","informationalModalMessageBody":"Показването на преглед позволява на Google (собственик на YouTube) да види част от информацията за Вашето устройство, но все пак осигурява повече поверителност отколкото при възпроизвеждане на видеоклипа.","informationalModalConfirmButtonText":"Активиране на всички прегледи","informationalModalRejectButtonText":"Не, благодаря","buttonTextUnblockVideo":"Разблокиране на видео от YouTube","infoTitleUnblockVideo":"DuckDuckGo блокира този видеоклип в YouTube, за да предотврати проследяване от Google","infoTextUnblockVideo":"Блокирахме проследяването от Google (собственик на YouTube) при зареждане на страницата. Ако разблокирате този видеоклип, Google ще следи Вашата активност.","infoPreviewToggleText":"Прегледите са деактивирани за осигуряване на допълнителна поверителност","infoPreviewToggleEnabledText":"Прегледите са активирани","infoPreviewToggleEnabledDuckDuckGoText":"Визуализациите от YouTube са активирани в DuckDuckGo.","infoPreviewInfoText":"Научете повече за вградената защита от социални медии на DuckDuckGo"}},"cs":{"facebook.json":{"informationalModalMessageTitle":"Když se přihlásíš přes Facebook, bude tě moct sledovat","informationalModalMessageBody":"Po přihlášení už DuckDuckGo nemůže bránit Facebooku, aby tě na téhle stránce sledoval.","informationalModalConfirmButtonText":"Přihlásit se","informationalModalRejectButtonText":"Zpět","loginButtonText":"Přihlásit se pomocí Facebooku","loginBodyText":"Facebook sleduje tvou aktivitu na webu, když se přihlásíš jeho prostřednictvím.","buttonTextUnblockContent":"Odblokovat obsah na Facebooku","buttonTextUnblockComment":"Odblokovat komentář na Facebooku","buttonTextUnblockComments":"Odblokovat komentáře na Facebooku","buttonTextUnblockPost":"Odblokovat příspěvek na Facebooku","buttonTextUnblockVideo":"Odblokovat video na Facebooku","buttonTextUnblockLogin":"Odblokovat přihlášení k Facebooku","infoTitleUnblockContent":"DuckDuckGo zablokoval tenhle obsah, aby Facebooku zabránil tě sledovat","infoTitleUnblockComment":"Služba DuckDuckGo zablokovala tento komentář, aby Facebooku zabránila ve tvém sledování","infoTitleUnblockComments":"Služba DuckDuckGo zablokovala tyto komentáře, aby Facebooku zabránila ve tvém sledování","infoTitleUnblockPost":"DuckDuckGo zablokoval tenhle příspěvek, aby Facebooku zabránil tě sledovat","infoTitleUnblockVideo":"DuckDuckGo zablokoval tohle video, aby Facebooku zabránil tě sledovat","infoTextUnblockContent":"Při načítání stránky jsme Facebooku zabránili, aby tě sledoval. Když tenhle obsah odblokuješ, Facebook bude mít přístup ke tvé aktivitě."},"shared.json":{"learnMore":"Více informací","readAbout":"Přečti si o téhle ochraně soukromí","shareFeedback":"Podělte se o zpětnou vazbu"},"youtube.json":{"informationalModalMessageTitle":"Zapnout všechny náhledy YouTube?","informationalModalMessageBody":"Zobrazování náhledů umožní společnosti Google (která vlastní YouTube) zobrazit některé informace o tvém zařízení, ale pořád jde o diskrétnější volbu, než je přehrávání videa.","informationalModalConfirmButtonText":"Zapnout všechny náhledy","informationalModalRejectButtonText":"Ne, děkuji","buttonTextUnblockVideo":"Odblokovat video na YouTube","infoTitleUnblockVideo":"DuckDuckGo zablokoval tohle video z YouTube, aby Googlu zabránil tě sledovat","infoTextUnblockVideo":"Zabránili jsme společnosti Google (která vlastní YouTube), aby tě při načítání stránky sledovala. Pokud toto video odblokuješ, Google získá přístup ke tvé aktivitě.","infoPreviewToggleText":"Náhledy jsou pro větší soukromí vypnuté","infoPreviewToggleEnabledText":"Náhledy jsou zapnuté","infoPreviewToggleEnabledDuckDuckGoText":"Náhledy YouTube jsou v DuckDuckGo povolené.","infoPreviewInfoText":"Další informace o ochraně DuckDuckGo před sledováním prostřednictvím vloženého obsahu ze sociálních médií"}},"da":{"facebook.json":{"informationalModalMessageTitle":"Når du logger ind med Facebook, kan de spore dig","informationalModalMessageBody":"Når du er logget ind, kan DuckDuckGo ikke blokere for, at indhold fra Facebook sporer dig på dette websted.","informationalModalConfirmButtonText":"Log på","informationalModalRejectButtonText":"Gå tilbage","loginButtonText":"Log ind med Facebook","loginBodyText":"Facebook sporer din aktivitet på et websted, når du bruger dem til at logge ind.","buttonTextUnblockContent":"Bloker ikke Facebook-indhold","buttonTextUnblockComment":"Bloker ikke Facebook-kommentar","buttonTextUnblockComments":"Bloker ikke Facebook-kommentarer","buttonTextUnblockPost":"Bloker ikke Facebook-opslag","buttonTextUnblockVideo":"Bloker ikke Facebook-video","buttonTextUnblockLogin":"Bloker ikke Facebook-login","infoTitleUnblockContent":"DuckDuckGo har blokeret dette indhold for at forhindre Facebook i at spore dig","infoTitleUnblockComment":"DuckDuckGo har blokeret denne kommentar for at forhindre Facebook i at spore dig","infoTitleUnblockComments":"DuckDuckGo har blokeret disse kommentarer for at forhindre Facebook i at spore dig","infoTitleUnblockPost":"DuckDuckGo blokerede dette indlæg for at forhindre Facebook i at spore dig","infoTitleUnblockVideo":"DuckDuckGo har blokeret denne video for at forhindre Facebook i at spore dig","infoTextUnblockContent":"Vi blokerede for, at Facebook sporede dig, da siden blev indlæst. Hvis du ophæver blokeringen af dette indhold, vil Facebook kende din aktivitet."},"shared.json":{"learnMore":"Mere info","readAbout":"Læs om denne beskyttelse af privatlivet","shareFeedback":"Del feedback"},"youtube.json":{"informationalModalMessageTitle":"Vil du aktivere alle YouTube-forhåndsvisninger?","informationalModalMessageBody":"Med forhåndsvisninger kan Google (som ejer YouTube) se nogle af enhedens oplysninger, men det er stadig mere privat end at afspille videoen.","informationalModalConfirmButtonText":"Aktivér alle forhåndsvisninger","informationalModalRejectButtonText":"Nej tak.","buttonTextUnblockVideo":"Bloker ikke YouTube-video","infoTitleUnblockVideo":"DuckDuckGo har blokeret denne YouTube-video for at forhindre Google i at spore dig","infoTextUnblockVideo":"Vi blokerede Google (som ejer YouTube) fra at spore dig, da siden blev indlæst. Hvis du fjerner blokeringen af denne video, vil Google få kendskab til din aktivitet.","infoPreviewToggleText":"Forhåndsvisninger er deaktiveret for at give yderligere privatliv","infoPreviewToggleEnabledText":"Forhåndsvisninger er deaktiveret","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-forhåndsvisninger er aktiveret i DuckDuckGo.","infoPreviewInfoText":"Få mere at vide på om DuckDuckGos indbyggede beskyttelse på sociale medier"}},"de":{"facebook.json":{"informationalModalMessageTitle":"Wenn du dich bei Facebook anmeldest, kann Facebook dich tracken","informationalModalMessageBody":"Sobald du angemeldet bist, kann DuckDuckGo nicht mehr verhindern, dass Facebook-Inhalte dich auf dieser Website tracken.","informationalModalConfirmButtonText":"Anmelden","informationalModalRejectButtonText":"Zurück","loginButtonText":"Mit Facebook anmelden","loginBodyText":"Facebook trackt deine Aktivität auf einer Website, wenn du dich über Facebook dort anmeldest.","buttonTextUnblockContent":"Facebook-Inhalt entsperren","buttonTextUnblockComment":"Facebook-Kommentar entsperren","buttonTextUnblockComments":"Facebook-Kommentare entsperren","buttonTextUnblockPost":"Facebook-Beitrag entsperren","buttonTextUnblockVideo":"Facebook-Video entsperren","buttonTextUnblockLogin":"Facebook-Anmeldung entsperren","infoTitleUnblockContent":"DuckDuckGo hat diesen Inhalt blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockComment":"DuckDuckGo hat diesen Kommentar blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockComments":"DuckDuckGo hat diese Kommentare blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockPost":"DuckDuckGo hat diesen Beitrag blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockVideo":"DuckDuckGo hat dieses Video blockiert, um zu verhindern, dass Facebook dich trackt","infoTextUnblockContent":"Wir haben Facebook daran gehindert, dich zu tracken, als die Seite geladen wurde. Wenn du die Blockierung für diesen Inhalt aufhebst, kennt Facebook deine Aktivitäten."},"shared.json":{"learnMore":"Mehr erfahren","readAbout":"Weitere Informationen über diesen Datenschutz","shareFeedback":"Feedback teilen"},"youtube.json":{"informationalModalMessageTitle":"Alle YouTube-Vorschauen aktivieren?","informationalModalMessageBody":"Durch das Anzeigen von Vorschauen kann Google (dem YouTube gehört) einige Informationen zu deinem Gerät sehen. Dies ist aber immer noch privater als das Abspielen des Videos.","informationalModalConfirmButtonText":"Alle Vorschauen aktivieren","informationalModalRejectButtonText":"Nein, danke","buttonTextUnblockVideo":"YouTube-Video entsperren","infoTitleUnblockVideo":"DuckDuckGo hat dieses YouTube-Video blockiert, um zu verhindern, dass Google dich trackt.","infoTextUnblockVideo":"Wir haben Google (dem YouTube gehört) daran gehindert, dich beim Laden der Seite zu tracken. Wenn du die Blockierung für dieses Video aufhebst, kennt Google deine Aktivitäten.","infoPreviewToggleText":"Vorschau für mehr Privatsphäre deaktiviert","infoPreviewToggleEnabledText":"Vorschau aktiviert","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-Vorschauen sind in DuckDuckGo aktiviert.","infoPreviewInfoText":"Erfahre mehr über den DuckDuckGo-Schutz vor eingebetteten Social Media-Inhalten"}},"el":{"facebook.json":{"informationalModalMessageTitle":"Η σύνδεση μέσω Facebook τους επιτρέπει να σας παρακολουθούν","informationalModalMessageBody":"Μόλις συνδεθείτε, το DuckDuckGo δεν μπορεί να εμποδίσει το περιεχόμενο του Facebook από το να σας παρακολουθεί σε αυτόν τον ιστότοπο.","informationalModalConfirmButtonText":"Σύνδεση","informationalModalRejectButtonText":"Επιστροφή","loginButtonText":"Σύνδεση μέσω Facebook","loginBodyText":"Το Facebook παρακολουθεί τη δραστηριότητά σας σε έναν ιστότοπο όταν τον χρησιμοποιείτε για να συνδεθείτε.","buttonTextUnblockContent":"Άρση αποκλεισμού περιεχομένου στο Facebook","buttonTextUnblockComment":"Άρση αποκλεισμού σχόλιου στο Facebook","buttonTextUnblockComments":"Άρση αποκλεισμού σχολίων στο Facebook","buttonTextUnblockPost":"Άρση αποκλεισμού ανάρτησης στο Facebook","buttonTextUnblockVideo":"Άρση αποκλεισμού βίντεο στο Facebook","buttonTextUnblockLogin":"Άρση αποκλεισμού σύνδεσης στο Facebook","infoTitleUnblockContent":"Το DuckDuckGo απέκλεισε το περιεχόμενο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockComment":"Το DuckDuckGo απέκλεισε το σχόλιο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockComments":"Το DuckDuckGo απέκλεισε τα σχόλια αυτά για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockPost":"Το DuckDuckGo απέκλεισε την ανάρτηση αυτή για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockVideo":"Το DuckDuckGo απέκλεισε το βίντεο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTextUnblockContent":"Αποκλείσαμε το Facebook από το να σας παρακολουθεί όταν φορτώθηκε η σελίδα. Εάν κάνετε άρση αποκλεισμού γι' αυτό το περιεχόμενο, το Facebook θα γνωρίζει τη δραστηριότητά σας."},"shared.json":{"learnMore":"Μάθετε περισσότερα","readAbout":"Διαβάστε σχετικά με την παρούσα προστασίας προσωπικών δεδομένων","shareFeedback":"Κοινοποίηση σχολίου"},"youtube.json":{"informationalModalMessageTitle":"Ενεργοποίηση όλων των προεπισκοπήσεων του YouTube;","informationalModalMessageBody":"Η προβολή των προεπισκοπήσεων θα επιτρέψει στην Google (στην οποία ανήκει το YouTube) να βλέπει ορισμένες από τις πληροφορίες της συσκευής σας, ωστόσο εξακολουθεί να είναι πιο ιδιωτική από την αναπαραγωγή του βίντεο.","informationalModalConfirmButtonText":"Ενεργοποίηση όλων των προεπισκοπήσεων","informationalModalRejectButtonText":"Όχι, ευχαριστώ","buttonTextUnblockVideo":"Άρση αποκλεισμού βίντεο YouTube","infoTitleUnblockVideo":"Το DuckDuckGo απέκλεισε το βίντεο αυτό στο YouTube για να εμποδίσει την Google από το να σας παρακολουθεί","infoTextUnblockVideo":"Αποκλείσαμε την Google (στην οποία ανήκει το YouTube) από το να σας παρακολουθεί όταν φορτώθηκε η σελίδα. Εάν κάνετε άρση αποκλεισμού γι' αυτό το βίντεο, η Google θα γνωρίζει τη δραστηριότητά σας.","infoPreviewToggleText":"Οι προεπισκοπήσεις απενεργοποιήθηκαν για πρόσθετη προστασία των προσωπικών δεδομένων","infoPreviewToggleEnabledText":"Οι προεπισκοπήσεις ενεργοποιήθηκαν","infoPreviewToggleEnabledDuckDuckGoText":"Οι προεπισκοπήσεις YouTube ενεργοποιήθηκαν στο DuckDuckGo.","infoPreviewInfoText":"Μάθετε περισσότερα για την ενσωματωμένη προστασία κοινωνικών μέσων DuckDuckGo"}},"en":{"facebook.json":{"informationalModalMessageTitle":"Logging in with Facebook lets them track you","informationalModalMessageBody":"Once you're logged in, DuckDuckGo can't block Facebook content from tracking you on this site.","informationalModalConfirmButtonText":"Log In","informationalModalRejectButtonText":"Go back","loginButtonText":"Log in with Facebook","loginBodyText":"Facebook tracks your activity on a site when you use them to login.","buttonTextUnblockContent":"Unblock Facebook Content","buttonTextUnblockComment":"Unblock Facebook Comment","buttonTextUnblockComments":"Unblock Facebook Comments","buttonTextUnblockPost":"Unblock Facebook Post","buttonTextUnblockVideo":"Unblock Facebook Video","buttonTextUnblockLogin":"Unblock Facebook Login","infoTitleUnblockContent":"DuckDuckGo blocked this content to prevent Facebook from tracking you","infoTitleUnblockComment":"DuckDuckGo blocked this comment to prevent Facebook from tracking you","infoTitleUnblockComments":"DuckDuckGo blocked these comments to prevent Facebook from tracking you","infoTitleUnblockPost":"DuckDuckGo blocked this post to prevent Facebook from tracking you","infoTitleUnblockVideo":"DuckDuckGo blocked this video to prevent Facebook from tracking you","infoTextUnblockContent":"We blocked Facebook from tracking you when the page loaded. If you unblock this content, Facebook will know your activity."},"shared.json":{"learnMore":"Learn More","readAbout":"Read about this privacy protection","shareFeedback":"Share Feedback"},"youtube.json":{"informationalModalMessageTitle":"Enable all YouTube previews?","informationalModalMessageBody":"Showing previews will allow Google (which owns YouTube) to see some of your device’s information, but is still more private than playing the video.","informationalModalConfirmButtonText":"Enable All Previews","informationalModalRejectButtonText":"No Thanks","buttonTextUnblockVideo":"Unblock YouTube Video","infoTitleUnblockVideo":"DuckDuckGo blocked this YouTube video to prevent Google from tracking you","infoTextUnblockVideo":"We blocked Google (which owns YouTube) from tracking you when the page loaded. If you unblock this video, Google will know your activity.","infoPreviewToggleText":"Previews disabled for additional privacy","infoPreviewToggleEnabledText":"Previews enabled","infoPreviewToggleEnabledDuckDuckGoText":"YouTube previews enabled in DuckDuckGo.","infoPreviewInfoText":"Learn more about DuckDuckGo Embedded Social Media Protection"}},"es":{"facebook.json":{"informationalModalMessageTitle":"Al iniciar sesión en Facebook, les permites que te rastreen","informationalModalMessageBody":"Una vez que hayas iniciado sesión, DuckDuckGo no puede bloquear el contenido de Facebook para que no te rastree en este sitio.","informationalModalConfirmButtonText":"Iniciar sesión","informationalModalRejectButtonText":"Volver atrás","loginButtonText":"Iniciar sesión con Facebook","loginBodyText":"Facebook rastrea tu actividad en un sitio web cuando lo usas para iniciar sesión.","buttonTextUnblockContent":"Desbloquear contenido de Facebook","buttonTextUnblockComment":"Desbloquear comentario de Facebook","buttonTextUnblockComments":"Desbloquear comentarios de Facebook","buttonTextUnblockPost":"Desbloquear publicación de Facebook","buttonTextUnblockVideo":"Desbloquear vídeo de Facebook","buttonTextUnblockLogin":"Desbloquear inicio de sesión de Facebook","infoTitleUnblockContent":"DuckDuckGo ha bloqueado este contenido para evitar que Facebook te rastree","infoTitleUnblockComment":"DuckDuckGo ha bloqueado este comentario para evitar que Facebook te rastree","infoTitleUnblockComments":"DuckDuckGo ha bloqueado estos comentarios para evitar que Facebook te rastree","infoTitleUnblockPost":"DuckDuckGo ha bloqueado esta publicación para evitar que Facebook te rastree","infoTitleUnblockVideo":"DuckDuckGo ha bloqueado este vídeo para evitar que Facebook te rastree","infoTextUnblockContent":"Hemos bloqueado el rastreo de Facebook cuando se ha cargado la página. Si desbloqueas este contenido, Facebook tendrá conocimiento de tu actividad."},"shared.json":{"learnMore":"Más información","readAbout":"Lee acerca de esta protección de privacidad","shareFeedback":"Compartir opiniones"},"youtube.json":{"informationalModalMessageTitle":"¿Habilitar todas las vistas previas de YouTube?","informationalModalMessageBody":"Mostrar vistas previas permitirá a Google (que es el propietario de YouTube) ver parte de la información de tu dispositivo, pero sigue siendo más privado que reproducir el vídeo.","informationalModalConfirmButtonText":"Habilitar todas las vistas previas","informationalModalRejectButtonText":"No, gracias","buttonTextUnblockVideo":"Desbloquear vídeo de YouTube","infoTitleUnblockVideo":"DuckDuckGo ha bloqueado este vídeo de YouTube para evitar que Google te rastree","infoTextUnblockVideo":"Hemos bloqueado el rastreo de Google (que es el propietario de YouTube) al cargarse la página. Si desbloqueas este vídeo, Goggle tendrá conocimiento de tu actividad.","infoPreviewToggleText":"Vistas previas desactivadas para mayor privacidad","infoPreviewToggleEnabledText":"Vistas previas activadas","infoPreviewToggleEnabledDuckDuckGoText":"Vistas previas de YouTube habilitadas en DuckDuckGo.","infoPreviewInfoText":"Más información sobre la protección integrada de redes sociales DuckDuckGo"}},"et":{"facebook.json":{"informationalModalMessageTitle":"Kui logid Facebookiga sisse, saab Facebook sind jälgida","informationalModalMessageBody":"Kui oled sisse logitud, ei saa DuckDuckGo blokeerida Facebooki sisu sind jälgimast.","informationalModalConfirmButtonText":"Logi sisse","informationalModalRejectButtonText":"Mine tagasi","loginButtonText":"Logi sisse Facebookiga","loginBodyText":"Kui logid sisse Facebookiga, saab Facebook sinu tegevust saidil jälgida.","buttonTextUnblockContent":"Deblokeeri Facebooki sisu","buttonTextUnblockComment":"Deblokeeri Facebooki kommentaar","buttonTextUnblockComments":"Deblokeeri Facebooki kommentaarid","buttonTextUnblockPost":"Deblokeeri Facebooki postitus","buttonTextUnblockVideo":"Deblokeeri Facebooki video","buttonTextUnblockLogin":"Deblokeeri Facebooki sisselogimine","infoTitleUnblockContent":"DuckDuckGo blokeeris selle sisu, et Facebook ei saaks sind jälgida","infoTitleUnblockComment":"DuckDuckGo blokeeris selle kommentaari, et Facebook ei saaks sind jälgida","infoTitleUnblockComments":"DuckDuckGo blokeeris need kommentaarid, et Facebook ei saaks sind jälgida","infoTitleUnblockPost":"DuckDuckGo blokeeris selle postituse, et Facebook ei saaks sind jälgida","infoTitleUnblockVideo":"DuckDuckGo blokeeris selle video, et Facebook ei saaks sind jälgida","infoTextUnblockContent":"Blokeerisime lehe laadimise ajal Facebooki jaoks sinu jälgimise. Kui sa selle sisu deblokeerid, saab Facebook sinu tegevust jälgida."},"shared.json":{"learnMore":"Loe edasi","readAbout":"Loe selle privaatsuskaitse kohta","shareFeedback":"Jaga tagasisidet"},"youtube.json":{"informationalModalMessageTitle":"Kas lubada kõik YouTube’i eelvaated?","informationalModalMessageBody":"Eelvaate näitamine võimaldab Google’il (kellele YouTube kuulub) näha osa sinu seadme teabest, kuid see on siiski privaatsem kui video esitamine.","informationalModalConfirmButtonText":"Luba kõik eelvaated","informationalModalRejectButtonText":"Ei aitäh","buttonTextUnblockVideo":"Deblokeeri YouTube’i video","infoTitleUnblockVideo":"DuckDuckGo blokeeris selle YouTube’i video, et takistada Google’it sind jälgimast","infoTextUnblockVideo":"Me blokeerisime lehe laadimise ajal Google’i (kellele YouTube kuulub) jälgimise. Kui sa selle video deblokeerid, saab Google sinu tegevusest teada.","infoPreviewToggleText":"Eelvaated on täiendava privaatsuse tagamiseks keelatud","infoPreviewToggleEnabledText":"Eelvaated on lubatud","infoPreviewToggleEnabledDuckDuckGoText":"YouTube’i eelvaated on DuckDuckGos lubatud.","infoPreviewInfoText":"Lisateave DuckDuckGo sisseehitatud sotsiaalmeediakaitse kohta"}},"fi":{"facebook.json":{"informationalModalMessageTitle":"Kun kirjaudut sisään Facebook-tunnuksilla, Facebook voi seurata sinua","informationalModalMessageBody":"Kun olet kirjautunut sisään, DuckDuckGo ei voi estää Facebook-sisältöä seuraamasta sinua tällä sivustolla.","informationalModalConfirmButtonText":"Kirjaudu sisään","informationalModalRejectButtonText":"Edellinen","loginButtonText":"Kirjaudu sisään Facebook-tunnuksilla","loginBodyText":"Facebook seuraa toimintaasi sivustolla, kun kirjaudut sisään sen kautta.","buttonTextUnblockContent":"Poista Facebook-sisällön esto","buttonTextUnblockComment":"Poista Facebook-kommentin esto","buttonTextUnblockComments":"Poista Facebook-kommenttien esto","buttonTextUnblockPost":"Poista Facebook-julkaisun esto","buttonTextUnblockVideo":"Poista Facebook-videon esto","buttonTextUnblockLogin":"Poista Facebook-kirjautumisen esto","infoTitleUnblockContent":"DuckDuckGo esti tämän sisällön estääkseen Facebookia seuraamasta sinua","infoTitleUnblockComment":"DuckDuckGo esti tämän kommentin estääkseen Facebookia seuraamasta sinua","infoTitleUnblockComments":"DuckDuckGo esti nämä kommentit estääkseen Facebookia seuraamasta sinua","infoTitleUnblockPost":"DuckDuckGo esti tämän julkaisun estääkseen Facebookia seuraamasta sinua","infoTitleUnblockVideo":"DuckDuckGo esti tämän videon estääkseen Facebookia seuraamasta sinua","infoTextUnblockContent":"Estimme Facebookia seuraamasta sinua, kun sivua ladattiin. Jos poistat tämän sisällön eston, Facebook saa tietää toimintasi."},"shared.json":{"learnMore":"Lue lisää","readAbout":"Lue tästä yksityisyydensuojasta","shareFeedback":"Jaa palaute"},"youtube.json":{"informationalModalMessageTitle":"Otetaanko käyttöön kaikki YouTube-esikatselut?","informationalModalMessageBody":"Kun sallit esikatselun, Google (joka omistaa YouTuben) voi nähdä joitakin laitteesi tietoja, mutta se on silti yksityisempää kuin videon toistaminen.","informationalModalConfirmButtonText":"Ota käyttöön kaikki esikatselut","informationalModalRejectButtonText":"Ei kiitos","buttonTextUnblockVideo":"Poista YouTube-videon esto","infoTitleUnblockVideo":"DuckDuckGo esti tämän YouTube-videon, jotta Google ei voi seurata sinua","infoTextUnblockVideo":"Estimme Googlea (joka omistaa YouTuben) seuraamasta sinua, kun sivua ladattiin. Jos poistat tämän videon eston, Google tietää toimintasi.","infoPreviewToggleText":"Esikatselut on poistettu käytöstä yksityisyyden lisäämiseksi","infoPreviewToggleEnabledText":"Esikatselut käytössä","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-esikatselut käytössä DuckDuckGossa.","infoPreviewInfoText":"Lue lisää DuckDuckGon upotetusta sosiaalisen median suojauksesta"}},"fr":{"facebook.json":{"informationalModalMessageTitle":"L'identification via Facebook leur permet de vous pister","informationalModalMessageBody":"Une fois que vous êtes connecté(e), DuckDuckGo ne peut pas empêcher le contenu Facebook de vous pister sur ce site.","informationalModalConfirmButtonText":"Connexion","informationalModalRejectButtonText":"Revenir en arrière","loginButtonText":"S'identifier avec Facebook","loginBodyText":"Facebook piste votre activité sur un site lorsque vous l'utilisez pour vous identifier.","buttonTextUnblockContent":"Débloquer le contenu Facebook","buttonTextUnblockComment":"Débloquer le commentaire Facebook","buttonTextUnblockComments":"Débloquer les commentaires Facebook","buttonTextUnblockPost":"Débloquer la publication Facebook","buttonTextUnblockVideo":"Débloquer la vidéo Facebook","buttonTextUnblockLogin":"Débloquer la connexion Facebook","infoTitleUnblockContent":"DuckDuckGo a bloqué ce contenu pour empêcher Facebook de vous suivre","infoTitleUnblockComment":"DuckDuckGo a bloqué ce commentaire pour empêcher Facebook de vous suivre","infoTitleUnblockComments":"DuckDuckGo a bloqué ces commentaires pour empêcher Facebook de vous suivre","infoTitleUnblockPost":"DuckDuckGo a bloqué cette publication pour empêcher Facebook de vous pister","infoTitleUnblockVideo":"DuckDuckGo a bloqué cette vidéo pour empêcher Facebook de vous pister","infoTextUnblockContent":"Nous avons empêché Facebook de vous pister lors du chargement de la page. Si vous débloquez ce contenu, Facebook connaîtra votre activité."},"shared.json":{"learnMore":"En savoir plus","readAbout":"En savoir plus sur cette protection de la confidentialité","shareFeedback":"Partagez vos commentaires"},"youtube.json":{"informationalModalMessageTitle":"Activer tous les aperçus YouTube ?","informationalModalMessageBody":"L'affichage des aperçus permettra à Google (propriétaire de YouTube) de voir certaines informations de votre appareil, mais cela reste davantage confidentiel qu'en lisant la vidéo.","informationalModalConfirmButtonText":"Activer tous les aperçus","informationalModalRejectButtonText":"Non merci","buttonTextUnblockVideo":"Débloquer la vidéo YouTube","infoTitleUnblockVideo":"DuckDuckGo a bloqué cette vidéo YouTube pour empêcher Google de vous pister","infoTextUnblockVideo":"Nous avons empêché Google (propriétaire de YouTube) de vous pister lors du chargement de la page. Si vous débloquez cette vidéo, Google connaîtra votre activité.","infoPreviewToggleText":"Aperçus désactivés pour plus de confidentialité","infoPreviewToggleEnabledText":"Aperçus activés","infoPreviewToggleEnabledDuckDuckGoText":"Les aperçus YouTube sont activés dans DuckDuckGo.","infoPreviewInfoText":"En savoir plus sur la protection intégrée DuckDuckGo des réseaux sociaux"}},"hr":{"facebook.json":{"informationalModalMessageTitle":"Prijava putem Facebooka omogućuje im da te prate","informationalModalMessageBody":"Nakon što se prijaviš, DuckDuckGo ne može blokirati Facebookov sadržaj da te prati na Facebooku.","informationalModalConfirmButtonText":"Prijavljivanje","informationalModalRejectButtonText":"Vrati se","loginButtonText":"Prijavi se putem Facebooka","loginBodyText":"Facebook prati tvoju aktivnost na toj web lokaciji kad je koristiš za prijavu.","buttonTextUnblockContent":"Deblokiraj sadržaj na Facebooku","buttonTextUnblockComment":"Deblokiraj komentar na Facebooku","buttonTextUnblockComments":"Deblokiraj komentare na Facebooku","buttonTextUnblockPost":"Deblokiraj objavu na Facebooku","buttonTextUnblockVideo":"Deblokiraj videozapis na Facebooku","buttonTextUnblockLogin":"Deblokiraj prijavu na Facebook","infoTitleUnblockContent":"DuckDuckGo je blokirao ovaj sadržaj kako bi spriječio Facebook da te prati","infoTitleUnblockComment":"DuckDuckGo je blokirao ovaj komentar kako bi spriječio Facebook da te prati","infoTitleUnblockComments":"DuckDuckGo je blokirao ove komentare kako bi spriječio Facebook da te prati","infoTitleUnblockPost":"DuckDuckGo je blokirao ovu objavu kako bi spriječio Facebook da te prati","infoTitleUnblockVideo":"DuckDuckGo je blokirao ovaj video kako bi spriječio Facebook da te prati","infoTextUnblockContent":"Blokirali smo Facebook da te prati kad se stranica učita. Ako deblokiraš ovaj sadržaj, Facebook će znati tvoju aktivnost."},"shared.json":{"learnMore":"Saznajte više","readAbout":"Pročitaj više o ovoj zaštiti privatnosti","shareFeedback":"Podijeli povratne informacije"},"youtube.json":{"informationalModalMessageTitle":"Omogućiti sve YouTube pretpreglede?","informationalModalMessageBody":"Prikazivanje pretpregleda omogućit će Googleu (u čijem je vlasništvu YouTube) da vidi neke podatke o tvom uređaju, ali je i dalje privatnija opcija od reprodukcije videozapisa.","informationalModalConfirmButtonText":"Omogući sve pretpreglede","informationalModalRejectButtonText":"Ne, hvala","buttonTextUnblockVideo":"Deblokiraj YouTube videozapis","infoTitleUnblockVideo":"DuckDuckGo je blokirao ovaj YouTube videozapis kako bi spriječio Google da te prati","infoTextUnblockVideo":"Blokirali smo Google (u čijem je vlasništvu YouTube) da te prati kad se stranica učita. Ako deblokiraš ovaj videozapis, Google će znati tvoju aktivnost.","infoPreviewToggleText":"Pretpregledi su onemogućeni radi dodatne privatnosti","infoPreviewToggleEnabledText":"Pretpregledi su omogućeni","infoPreviewToggleEnabledDuckDuckGoText":"YouTube pretpregledi omogućeni su u DuckDuckGou.","infoPreviewInfoText":"Saznaj više o uključenoj DuckDuckGo zaštiti od društvenih medija"}},"hu":{"facebook.json":{"informationalModalMessageTitle":"A Facebookkal való bejelentkezéskor a Facebook nyomon követhet","informationalModalMessageBody":"Miután bejelentkezel, a DuckDuckGo nem fogja tudni blokkolni a Facebook-tartalmat, amely nyomon követ ezen az oldalon.","informationalModalConfirmButtonText":"Bejelentkezés","informationalModalRejectButtonText":"Visszalépés","loginButtonText":"Bejelentkezés Facebookkal","loginBodyText":"Ha a Facebookkal jelentkezel be, nyomon követik a webhelyen végzett tevékenységedet.","buttonTextUnblockContent":"Facebook-tartalom feloldása","buttonTextUnblockComment":"Facebook-hozzászólás feloldása","buttonTextUnblockComments":"Facebook-hozzászólások feloldása","buttonTextUnblockPost":"Facebook-bejegyzés feloldása","buttonTextUnblockVideo":"Facebook-videó feloldása","buttonTextUnblockLogin":"Facebook-bejelentkezés feloldása","infoTitleUnblockContent":"A DuckDuckGo blokkolta ezt a tartalmat, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockComment":"A DuckDuckGo blokkolta ezt a hozzászólást, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockComments":"A DuckDuckGo blokkolta ezeket a hozzászólásokat, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockPost":"A DuckDuckGo blokkolta ezt a bejegyzést, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockVideo":"A DuckDuckGo blokkolta ezt a videót, hogy megakadályozza a Facebookot a nyomon követésedben","infoTextUnblockContent":"Az oldal betöltésekor blokkoltuk a Facebookot a nyomon követésedben. Ha feloldod ezt a tartalmat, a Facebook tudni fogja, hogy milyen tevékenységet végzel."},"shared.json":{"learnMore":"További részletek","readAbout":"Tudj meg többet erről az adatvédelemről","shareFeedback":"Visszajelzés megosztása"},"youtube.json":{"informationalModalMessageTitle":"Engedélyezed minden YouTube-videó előnézetét?","informationalModalMessageBody":"Az előnézetek megjelenítésével a Google (a YouTube tulajdonosa) láthatja a készülék néhány adatát, de ez adatvédelmi szempontból még mindig előnyösebb, mint a videó lejátszása.","informationalModalConfirmButtonText":"Minden előnézet engedélyezése","informationalModalRejectButtonText":"Nem, köszönöm","buttonTextUnblockVideo":"YouTube-videó feloldása","infoTitleUnblockVideo":"A DuckDuckGo blokkolta a YouTube-videót, hogy a Google ne követhessen nyomon","infoTextUnblockVideo":"Blokkoltuk, hogy a Google (a YouTube tulajdonosa) nyomon követhessen az oldal betöltésekor. Ha feloldod a videó blokkolását, a Google tudni fogja, hogy milyen tevékenységet végzel.","infoPreviewToggleText":"Az előnézetek a fokozott adatvédelem érdekében letiltva","infoPreviewToggleEnabledText":"Az előnézetek engedélyezve","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-előnézetek engedélyezve a DuckDuckGo-ban.","infoPreviewInfoText":"További tudnivalók a DuckDuckGo beágyazott közösségi média elleni védelméről"}},"it":{"facebook.json":{"informationalModalMessageTitle":"L'accesso con Facebook consente di tracciarti","informationalModalMessageBody":"Dopo aver effettuato l'accesso, DuckDuckGo non può bloccare il tracciamento dei contenuti di Facebook su questo sito.","informationalModalConfirmButtonText":"Accedi","informationalModalRejectButtonText":"Torna indietro","loginButtonText":"Accedi con Facebook","loginBodyText":"Facebook tiene traccia della tua attività su un sito quando lo usi per accedere.","buttonTextUnblockContent":"Sblocca i contenuti di Facebook","buttonTextUnblockComment":"Sblocca il commento di Facebook","buttonTextUnblockComments":"Sblocca i commenti di Facebook","buttonTextUnblockPost":"Sblocca post di Facebook","buttonTextUnblockVideo":"Sblocca video di Facebook","buttonTextUnblockLogin":"Sblocca l'accesso a Facebook","infoTitleUnblockContent":"DuckDuckGo ha bloccato questo contenuto per impedire a Facebook di tracciarti","infoTitleUnblockComment":"DuckDuckGo ha bloccato questo commento per impedire a Facebook di tracciarti","infoTitleUnblockComments":"DuckDuckGo ha bloccato questi commenti per impedire a Facebook di tracciarti","infoTitleUnblockPost":"DuckDuckGo ha bloccato questo post per impedire a Facebook di tracciarti","infoTitleUnblockVideo":"DuckDuckGo ha bloccato questo video per impedire a Facebook di tracciarti","infoTextUnblockContent":"Abbiamo impedito a Facebook di tracciarti al caricamento della pagina. Se sblocchi questo contenuto, Facebook conoscerà la tua attività."},"shared.json":{"learnMore":"Ulteriori informazioni","readAbout":"Leggi di più su questa protezione della privacy","shareFeedback":"Condividi feedback"},"youtube.json":{"informationalModalMessageTitle":"Abilitare tutte le anteprime di YouTube?","informationalModalMessageBody":"La visualizzazione delle anteprime consentirà a Google (che possiede YouTube) di vedere alcune delle informazioni del tuo dispositivo, ma è comunque più privato rispetto alla riproduzione del video.","informationalModalConfirmButtonText":"Abilita tutte le anteprime","informationalModalRejectButtonText":"No, grazie","buttonTextUnblockVideo":"Sblocca video YouTube","infoTitleUnblockVideo":"DuckDuckGo ha bloccato questo video di YouTube per impedire a Google di tracciarti","infoTextUnblockVideo":"Abbiamo impedito a Google (che possiede YouTube) di tracciarti quando la pagina è stata caricata. Se sblocchi questo video, Google conoscerà la tua attività.","infoPreviewToggleText":"Anteprime disabilitate per una maggiore privacy","infoPreviewToggleEnabledText":"Anteprime abilitate","infoPreviewToggleEnabledDuckDuckGoText":"Anteprime YouTube abilitate in DuckDuckGo.","infoPreviewInfoText":"Scopri di più sulla protezione dai social media integrata di DuckDuckGo"}},"lt":{"facebook.json":{"informationalModalMessageTitle":"Prisijungę prie „Facebook“ galite būti sekami","informationalModalMessageBody":"Kai esate prisijungę, „DuckDuckGo“ negali užblokuoti „Facebook“ turinio, todėl esate sekami šioje svetainėje.","informationalModalConfirmButtonText":"Prisijungti","informationalModalRejectButtonText":"Grįžti atgal","loginButtonText":"Prisijunkite su „Facebook“","loginBodyText":"„Facebook“ seka jūsų veiklą svetainėje, kai prisijungiate su šia svetaine.","buttonTextUnblockContent":"Atblokuoti „Facebook“ turinį","buttonTextUnblockComment":"Atblokuoti „Facebook“ komentarą","buttonTextUnblockComments":"Atblokuoti „Facebook“ komentarus","buttonTextUnblockPost":"Atblokuoti „Facebook“ įrašą","buttonTextUnblockVideo":"Atblokuoti „Facebook“ vaizdo įrašą","buttonTextUnblockLogin":"Atblokuoti „Facebook“ prisijungimą","infoTitleUnblockContent":"„DuckDuckGo“ užblokavo šį turinį, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockComment":"„DuckDuckGo“ užblokavo šį komentarą, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockComments":"„DuckDuckGo“ užblokavo šiuos komentarus, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockPost":"„DuckDuckGo“ užblokavo šį įrašą, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockVideo":"„DuckDuckGo“ užblokavo šį vaizdo įrašą, kad „Facebook“ negalėtų jūsų sekti","infoTextUnblockContent":"Užblokavome „Facebook“, kad negalėtų jūsų sekti, kai puslapis buvo įkeltas. Jei atblokuosite šį turinį, „Facebook“ žinos apie jūsų veiklą."},"shared.json":{"learnMore":"Sužinoti daugiau","readAbout":"Skaitykite apie šią privatumo apsaugą","shareFeedback":"Bendrinti atsiliepimą"},"youtube.json":{"informationalModalMessageTitle":"Įjungti visas „YouTube“ peržiūras?","informationalModalMessageBody":"Peržiūrų rodymas leis „Google“ (kuriai priklauso „YouTube“) matyti tam tikrą jūsų įrenginio informaciją, tačiau ji vis tiek bus privatesnė nei leidžiant vaizdo įrašą.","informationalModalConfirmButtonText":"Įjungti visas peržiūras","informationalModalRejectButtonText":"Ne, dėkoju","buttonTextUnblockVideo":"Atblokuoti „YouTube“ vaizdo įrašą","infoTitleUnblockVideo":"„DuckDuckGo“ užblokavo šį „YouTube“ vaizdo įrašą, kad „Google“ negalėtų jūsų sekti","infoTextUnblockVideo":"Užblokavome „Google“ (kuriai priklauso „YouTube“) galimybę sekti jus, kai puslapis buvo įkeltas. Jei atblokuosite šį vaizdo įrašą, „Google“ sužinos apie jūsų veiklą.","infoPreviewToggleText":"Peržiūros išjungtos dėl papildomo privatumo","infoPreviewToggleEnabledText":"Peržiūros įjungtos","infoPreviewToggleEnabledDuckDuckGoText":"„YouTube“ peržiūros įjungtos „DuckDuckGo“.","infoPreviewInfoText":"Sužinokite daugiau apie „DuckDuckGo“ įdėtąją socialinės žiniasklaidos apsaugą"}},"lv":{"facebook.json":{"informationalModalMessageTitle":"Ja pieteiksies ar Facebook, viņi varēs tevi izsekot","informationalModalMessageBody":"Kad tu piesakies, DuckDuckGo nevar novērst, ka Facebook saturs tevi izseko šajā vietnē.","informationalModalConfirmButtonText":"Pieteikties","informationalModalRejectButtonText":"Atgriezties","loginButtonText":"Pieteikties ar Facebook","loginBodyText":"Facebook izseko tavas aktivitātes vietnē, kad esi pieteicies ar Facebook.","buttonTextUnblockContent":"Atbloķēt Facebook saturu","buttonTextUnblockComment":"Atbloķēt Facebook komentāru","buttonTextUnblockComments":"Atbloķēt Facebook komentārus","buttonTextUnblockPost":"Atbloķēt Facebook ziņu","buttonTextUnblockVideo":"Atbloķēt Facebook video","buttonTextUnblockLogin":"Atbloķēt Facebook pieteikšanos","infoTitleUnblockContent":"DuckDuckGo bloķēja šo saturu, lai neļautu Facebook tevi izsekot","infoTitleUnblockComment":"DuckDuckGo bloķēja šo komentāru, lai neļautu Facebook tevi izsekot","infoTitleUnblockComments":"DuckDuckGo bloķēja šos komentārus, lai neļautu Facebook tevi izsekot","infoTitleUnblockPost":"DuckDuckGo bloķēja šo ziņu, lai neļautu Facebook tevi izsekot","infoTitleUnblockVideo":"DuckDuckGo bloķēja šo videoklipu, lai neļautu Facebook tevi izsekot","infoTextUnblockContent":"Mēs bloķējām Facebook iespēju tevi izsekot, ielādējot lapu. Ja atbloķēsi šo saturu, Facebook redzēs, ko tu dari."},"shared.json":{"learnMore":"Uzzināt vairāk","readAbout":"Lasi par šo privātuma aizsardzību","shareFeedback":"Kopīgot atsauksmi"},"youtube.json":{"informationalModalMessageTitle":"Vai iespējot visus YouTube priekšskatījumus?","informationalModalMessageBody":"Priekšskatījumu rādīšana ļaus Google (kam pieder YouTube) redzēt daļu tavas ierīces informācijas, taču tas tāpat ir privātāk par videoklipa atskaņošanu.","informationalModalConfirmButtonText":"Iespējot visus priekšskatījumus","informationalModalRejectButtonText":"Nē, paldies","buttonTextUnblockVideo":"Atbloķēt YouTube videoklipu","infoTitleUnblockVideo":"DuckDuckGo bloķēja šo YouTube videoklipu, lai neļautu Google tevi izsekot","infoTextUnblockVideo":"Mēs neļāvām Google (kam pieder YouTube) tevi izsekot, kad lapa tika ielādēta. Ja atbloķēsi šo videoklipu, Google zinās, ko tu dari.","infoPreviewToggleText":"Priekšskatījumi ir atspējoti, lai nodrošinātu papildu konfidencialitāti","infoPreviewToggleEnabledText":"Priekšskatījumi ir iespējoti","infoPreviewToggleEnabledDuckDuckGoText":"DuckDuckGo iespējoti YouTube priekšskatījumi.","infoPreviewInfoText":"Uzzini vairāk par DuckDuckGo iegulto sociālo mediju aizsardzību"}},"nb":{"facebook.json":{"informationalModalMessageTitle":"Når du logger på med Facebook, kan de spore deg","informationalModalMessageBody":"Når du er logget på, kan ikke DuckDuckGo hindre Facebook-innhold i å spore deg på dette nettstedet.","informationalModalConfirmButtonText":"Logg inn","informationalModalRejectButtonText":"Gå tilbake","loginButtonText":"Logg på med Facebook","loginBodyText":"Når du logger på med Facebook, sporer de aktiviteten din på nettstedet.","buttonTextUnblockContent":"Fjern blokkering av Facebook-innhold","buttonTextUnblockComment":"Fjern blokkering av Facebook-kommentar","buttonTextUnblockComments":"Fjern blokkering av Facebook-kommentarer","buttonTextUnblockPost":"Fjern blokkering av Facebook-innlegg","buttonTextUnblockVideo":"Fjern blokkering av Facebook-video","buttonTextUnblockLogin":"Fjern blokkering av Facebook-pålogging","infoTitleUnblockContent":"DuckDuckGo blokkerte dette innholdet for å hindre Facebook i å spore deg","infoTitleUnblockComment":"DuckDuckGo blokkerte denne kommentaren for å hindre Facebook i å spore deg","infoTitleUnblockComments":"DuckDuckGo blokkerte disse kommentarene for å hindre Facebook i å spore deg","infoTitleUnblockPost":"DuckDuckGo blokkerte dette innlegget for å hindre Facebook i å spore deg","infoTitleUnblockVideo":"DuckDuckGo blokkerte denne videoen for å hindre Facebook i å spore deg","infoTextUnblockContent":"Vi hindret Facebook i å spore deg da siden ble lastet. Hvis du opphever blokkeringen av dette innholdet, får Facebook vite om aktiviteten din."},"shared.json":{"learnMore":"Finn ut mer","readAbout":"Les om denne personvernfunksjonen","shareFeedback":"Del tilbakemelding"},"youtube.json":{"informationalModalMessageTitle":"Vil du aktivere alle YouTube-forhåndsvisninger?","informationalModalMessageBody":"Forhåndsvisninger gjør det mulig for Google (som eier YouTube) å se enkelte opplysninger om enheten din, men det er likevel mer privat enn å spille av videoen.","informationalModalConfirmButtonText":"Aktiver alle forhåndsvisninger","informationalModalRejectButtonText":"Nei takk","buttonTextUnblockVideo":"Fjern blokkering av YouTube-video","infoTitleUnblockVideo":"DuckDuckGo blokkerte denne YouTube-videoen for å hindre Google i å spore deg","infoTextUnblockVideo":"Vi blokkerte Google (som eier YouTube) mot å spore deg da siden ble lastet. Hvis du opphever blokkeringen av denne videoen, får Google vite om aktiviteten din.","infoPreviewToggleText":"Forhåndsvisninger er deaktivert for å gi deg ekstra personvern","infoPreviewToggleEnabledText":"Forhåndsvisninger er aktivert","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-forhåndsvisninger er aktivert i DuckDuckGo.","infoPreviewInfoText":"Finn ut mer om DuckDuckGos innebygde beskyttelse for sosiale medier"}},"nl":{"facebook.json":{"informationalModalMessageTitle":"Als je inlogt met Facebook, kunnen zij je volgen","informationalModalMessageBody":"Als je eenmaal bent ingelogd, kan DuckDuckGo niet voorkomen dat Facebook je op deze site volgt.","informationalModalConfirmButtonText":"Inloggen","informationalModalRejectButtonText":"Terug","loginButtonText":"Inloggen met Facebook","loginBodyText":"Facebook volgt je activiteit op een site als je Facebook gebruikt om in te loggen.","buttonTextUnblockContent":"Facebook-inhoud deblokkeren","buttonTextUnblockComment":"Facebook-opmerkingen deblokkeren","buttonTextUnblockComments":"Facebook-opmerkingen deblokkeren","buttonTextUnblockPost":"Facebook-bericht deblokkeren","buttonTextUnblockVideo":"Facebook-video deblokkeren","buttonTextUnblockLogin":"Facebook-aanmelding deblokkeren","infoTitleUnblockContent":"DuckDuckGo heeft deze inhoud geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockComment":"DuckDuckGo heeft deze opmerking geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockComments":"DuckDuckGo heeft deze opmerkingen geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockPost":"DuckDuckGo heeft dit bericht geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockVideo":"DuckDuckGo heeft deze video geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTextUnblockContent":"We hebben voorkomen dat Facebook je volgde toen de pagina werd geladen. Als je deze inhoud deblokkeert, kan Facebook je activiteit zien."},"shared.json":{"learnMore":"Meer informatie","readAbout":"Lees meer over deze privacybescherming","shareFeedback":"Feedback delen"},"youtube.json":{"informationalModalMessageTitle":"Alle YouTube-voorbeelden inschakelen?","informationalModalMessageBody":"Bij het tonen van voorbeelden kan Google (eigenaar van YouTube) een deel van de informatie over je apparaat zien, maar blijft je privacy beter beschermd dan als je de video zou afspelen.","informationalModalConfirmButtonText":"Alle voorbeelden inschakelen","informationalModalRejectButtonText":"Nee, bedankt","buttonTextUnblockVideo":"YouTube-video deblokkeren","infoTitleUnblockVideo":"DuckDuckGo heeft deze YouTube-video geblokkeerd om te voorkomen dat Google je kan volgen","infoTextUnblockVideo":"We hebben voorkomen dat Google (eigenaar van YouTube) je volgde toen de pagina werd geladen. Als je deze video deblokkeert, kan Google je activiteit zien.","infoPreviewToggleText":"Voorbeelden uitgeschakeld voor extra privacy","infoPreviewToggleEnabledText":"Voorbeelden ingeschakeld","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-voorbeelden ingeschakeld in DuckDuckGo.","infoPreviewInfoText":"Meer informatie over DuckDuckGo's bescherming tegen ingesloten social media"}},"pl":{"facebook.json":{"informationalModalMessageTitle":"Jeśli zalogujesz się za pośrednictwem Facebooka, będzie on mógł śledzić Twoją aktywność","informationalModalMessageBody":"Po zalogowaniu się DuckDuckGo nie może zablokować możliwości śledzenia Cię przez Facebooka na tej stronie.","informationalModalConfirmButtonText":"Zaloguj się","informationalModalRejectButtonText":"Wróć","loginButtonText":"Zaloguj się za pośrednictwem Facebooka","loginBodyText":"Facebook śledzi Twoją aktywność na stronie, gdy logujesz się za jego pośrednictwem.","buttonTextUnblockContent":"Odblokuj treść na Facebooku","buttonTextUnblockComment":"Odblokuj komentarz na Facebooku","buttonTextUnblockComments":"Odblokuj komentarze na Facebooku","buttonTextUnblockPost":"Odblokuj post na Facebooku","buttonTextUnblockVideo":"Odblokuj wideo na Facebooku","buttonTextUnblockLogin":"Odblokuj logowanie na Facebooku","infoTitleUnblockContent":"DuckDuckGo zablokował tę treść, aby Facebook nie mógł Cię śledzić","infoTitleUnblockComment":"DuckDuckGo zablokował ten komentarz, aby Facebook nie mógł Cię śledzić","infoTitleUnblockComments":"DuckDuckGo zablokował te komentarze, aby Facebook nie mógł Cię śledzić","infoTitleUnblockPost":"DuckDuckGo zablokował ten post, aby Facebook nie mógł Cię śledzić","infoTitleUnblockVideo":"DuckDuckGo zablokował tę treść wideo, aby Facebook nie mógł Cię śledzić.","infoTextUnblockContent":"Zablokowaliśmy Facebookowi możliwość śledzenia Cię podczas ładowania strony. Jeśli odblokujesz tę treść, Facebook uzyska informacje o Twojej aktywności."},"shared.json":{"learnMore":"Dowiedz się więcej","readAbout":"Dowiedz się więcej o tej ochronie prywatności","shareFeedback":"Podziel się opinią"},"youtube.json":{"informationalModalMessageTitle":"Włączyć wszystkie podglądy w YouTube?","informationalModalMessageBody":"Wyświetlanie podglądu pozwala Google (który jest właścicielem YouTube) zobaczyć niektóre informacje o Twoim urządzeniu, ale nadal jest to bardziej prywatne niż odtwarzanie filmu.","informationalModalConfirmButtonText":"Włącz wszystkie podglądy","informationalModalRejectButtonText":"Nie, dziękuję","buttonTextUnblockVideo":"Odblokuj wideo w YouTube","infoTitleUnblockVideo":"DuckDuckGo zablokował ten film w YouTube, aby uniemożliwić Google śledzenie Twojej aktywności","infoTextUnblockVideo":"Zablokowaliśmy możliwość śledzenia Cię przez Google (właściciela YouTube) podczas ładowania strony. Jeśli odblokujesz ten film, Google zobaczy Twoją aktywność.","infoPreviewToggleText":"Podglądy zostały wyłączone, aby zapewnić większą ptywatność","infoPreviewToggleEnabledText":"Podglądy włączone","infoPreviewToggleEnabledDuckDuckGoText":"Podglądy YouTube włączone w DuckDuckGo.","infoPreviewInfoText":"Dowiedz się więcej o zabezpieczeniu osadzonych treści społecznościowych DuckDuckGo"}},"pt":{"facebook.json":{"informationalModalMessageTitle":"Iniciar sessão no Facebook permite que este te rastreie","informationalModalMessageBody":"Depois de iniciares sessão, o DuckDuckGo não poderá bloquear o rastreio por parte do conteúdo do Facebook neste site.","informationalModalConfirmButtonText":"Iniciar sessão","informationalModalRejectButtonText":"Retroceder","loginButtonText":"Iniciar sessão com o Facebook","loginBodyText":"O Facebook rastreia a tua atividade num site quando o usas para iniciares sessão.","buttonTextUnblockContent":"Desbloquear Conteúdo do Facebook","buttonTextUnblockComment":"Desbloquear Comentário do Facebook","buttonTextUnblockComments":"Desbloquear Comentários do Facebook","buttonTextUnblockPost":"Desbloquear Publicação no Facebook","buttonTextUnblockVideo":"Desbloquear Vídeo do Facebook","buttonTextUnblockLogin":"Desbloquear Início de Sessão no Facebook","infoTitleUnblockContent":"O DuckDuckGo bloqueou este conteúdo para evitar que o Facebook te rastreie","infoTitleUnblockComment":"O DuckDuckGo bloqueou este comentário para evitar que o Facebook te rastreie","infoTitleUnblockComments":"O DuckDuckGo bloqueou estes comentários para evitar que o Facebook te rastreie","infoTitleUnblockPost":"O DuckDuckGo bloqueou esta publicação para evitar que o Facebook te rastreie","infoTitleUnblockVideo":"O DuckDuckGo bloqueou este vídeo para evitar que o Facebook te rastreie","infoTextUnblockContent":"Bloqueámos o rastreio por parte do Facebook quando a página foi carregada. Se desbloqueares este conteúdo, o Facebook fica a saber a tua atividade."},"shared.json":{"learnMore":"Saiba mais","readAbout":"Ler mais sobre esta proteção de privacidade","shareFeedback":"Partilhar comentários"},"youtube.json":{"informationalModalMessageTitle":"Ativar todas as pré-visualizações do YouTube?","informationalModalMessageBody":"Mostrar visualizações permite à Google (que detém o YouTube) ver algumas das informações do teu dispositivo, mas ainda é mais privado do que reproduzir o vídeo.","informationalModalConfirmButtonText":"Ativar todas as pré-visualizações","informationalModalRejectButtonText":"Não, obrigado","buttonTextUnblockVideo":"Desbloquear Vídeo do YouTube","infoTitleUnblockVideo":"O DuckDuckGo bloqueou este vídeo do YouTube para impedir que a Google te rastreie","infoTextUnblockVideo":"Bloqueámos o rastreio por parte da Google (que detém o YouTube) quando a página foi carregada. Se desbloqueares este vídeo, a Google fica a saber a tua atividade.","infoPreviewToggleText":"Pré-visualizações desativadas para privacidade adicional","infoPreviewToggleEnabledText":"Pré-visualizações ativadas","infoPreviewToggleEnabledDuckDuckGoText":"Pré-visualizações do YouTube ativadas no DuckDuckGo.","infoPreviewInfoText":"Saiba mais sobre a Proteção contra conteúdos de redes sociais incorporados do DuckDuckGo"}},"ro":{"facebook.json":{"informationalModalMessageTitle":"Conectarea cu Facebook îi permite să te urmărească","informationalModalMessageBody":"Odată ce te-ai conectat, DuckDuckGo nu poate împiedica conținutul Facebook să te urmărească pe acest site.","informationalModalConfirmButtonText":"Autentificare","informationalModalRejectButtonText":"Înapoi","loginButtonText":"Conectează-te cu Facebook","loginBodyText":"Facebook urmărește activitatea ta pe un site atunci când îl utilizezi pentru a te conecta.","buttonTextUnblockContent":"Deblochează conținutul Facebook","buttonTextUnblockComment":"Deblochează comentariul de pe Facebook","buttonTextUnblockComments":"Deblochează comentariile de pe Facebook","buttonTextUnblockPost":"Deblochează postarea de pe Facebook","buttonTextUnblockVideo":"Deblochează videoclipul de pe Facebook","buttonTextUnblockLogin":"Deblochează conectarea cu Facebook","infoTitleUnblockContent":"DuckDuckGo a blocat acest conținut pentru a împiedica Facebook să te urmărească","infoTitleUnblockComment":"DuckDuckGo a blocat acest comentariu pentru a împiedica Facebook să te urmărească","infoTitleUnblockComments":"DuckDuckGo a blocat aceste comentarii pentru a împiedica Facebook să te urmărească","infoTitleUnblockPost":"DuckDuckGo a blocat această postare pentru a împiedica Facebook să te urmărească","infoTitleUnblockVideo":"DuckDuckGo a blocat acest videoclip pentru a împiedica Facebook să te urmărească","infoTextUnblockContent":"Am împiedicat Facebook să te urmărească atunci când pagina a fost încărcată. Dacă deblochezi acest conținut, Facebook îți va cunoaște activitatea."},"shared.json":{"learnMore":"Află mai multe","readAbout":"Citește despre această protecție a confidențialității","shareFeedback":"Partajează feedback"},"youtube.json":{"informationalModalMessageTitle":"Activezi toate previzualizările YouTube?","informationalModalMessageBody":"Afișarea previzualizărilor va permite ca Google (care deține YouTube) să vadă unele dintre informațiile despre dispozitivul tău, dar este totuși mai privată decât redarea videoclipului.","informationalModalConfirmButtonText":"Activează toate previzualizările","informationalModalRejectButtonText":"Nu, mulțumesc","buttonTextUnblockVideo":"Deblochează videoclipul de pe YouTube","infoTitleUnblockVideo":"DuckDuckGo a blocat acest videoclip de pe YouTube pentru a împiedica Google să te urmărească","infoTextUnblockVideo":"Am împiedicat Google (care deține YouTube) să te urmărească atunci când s-a încărcat pagina. Dacă deblochezi acest videoclip, Google va cunoaște activitatea ta.","infoPreviewToggleText":"Previzualizările au fost dezactivate pentru o confidențialitate suplimentară","infoPreviewToggleEnabledText":"Previzualizări activate","infoPreviewToggleEnabledDuckDuckGoText":"Previzualizările YouTube sunt activate în DuckDuckGo.","infoPreviewInfoText":"Află mai multe despre Protecția integrată DuckDuckGo pentru rețelele sociale"}},"ru":{"facebook.json":{"informationalModalMessageTitle":"Вход через Facebook позволяет этой социальной сети отслеживать вас","informationalModalMessageBody":"После входа DuckDuckGo не сможет блокировать отслеживание ваших действий с контентом на Facebook.","informationalModalConfirmButtonText":"Войти","informationalModalRejectButtonText":"Вернуться","loginButtonText":"Войти через Facebook","loginBodyText":"При использовании учётной записи Facebook для входа на сайты эта социальная сеть сможет отслеживать на них ваши действия.","buttonTextUnblockContent":"Разблокировать контент из Facebook","buttonTextUnblockComment":"Разблокировать комментарий из Facebook","buttonTextUnblockComments":"Разблокировать комментарии из Facebook","buttonTextUnblockPost":"Разблокировать публикацию из Facebook","buttonTextUnblockVideo":"Разблокировать видео из Facebook","buttonTextUnblockLogin":"Разблокировать окно входа в Facebook","infoTitleUnblockContent":"DuckDuckGo заблокировал этот контент, чтобы вас не отслеживал Facebook","infoTitleUnblockComment":"DuckDuckGo заблокировал этот комментарий, чтобы вас не отслеживал Facebook","infoTitleUnblockComments":"DuckDuckGo заблокировал эти комментарии, чтобы вас не отслеживал Facebook","infoTitleUnblockPost":"DuckDuckGo заблокировал эту публикацию, чтобы вас не отслеживал Facebook","infoTitleUnblockVideo":"DuckDuckGo заблокировал это видео, чтобы вас не отслеживал Facebook","infoTextUnblockContent":"Во время загрузки страницы мы помешали Facebook отследить ваши действия. Если разблокировать этот контент, Facebook сможет фиксировать вашу активность."},"shared.json":{"learnMore":"Узнать больше","readAbout":"Подробнее об этом виде защиты конфиденциальности","shareFeedback":"Оставьте нам отзыв"},"youtube.json":{"informationalModalMessageTitle":"Включить предпросмотр видео из YouTube?","informationalModalMessageBody":"Включение предварительного просмотра позволит Google (владельцу YouTube) получить некоторые сведения о вашем устройстве, однако это более безопасный вариант, чем воспроизведение видео целиком.","informationalModalConfirmButtonText":"Включить предпросмотр","informationalModalRejectButtonText":"Нет, спасибо","buttonTextUnblockVideo":"Разблокировать видео из YouTube","infoTitleUnblockVideo":"DuckDuckGo заблокировал это видео из YouTube, чтобы вас не отслеживал Google","infoTextUnblockVideo":"Во время загрузки страницы мы помешали Google (владельцу YouTube) отследить ваши действия. Если разблокировать видео, Google сможет фиксировать вашу активность.","infoPreviewToggleText":"Предварительный просмотр отключён для дополнительной защиты конфиденциальности","infoPreviewToggleEnabledText":"Предварительный просмотр включён","infoPreviewToggleEnabledDuckDuckGoText":"В DuckDuckGo включён предпросмотр видео из YouTube.","infoPreviewInfoText":"Подробнее о защите DuckDuckGo от внедрённого контента соцсетей"}},"sk":{"facebook.json":{"informationalModalMessageTitle":"Prihlásenie cez Facebook mu umožní sledovať vás","informationalModalMessageBody":"DuckDuckGo po prihlásení nemôže na tejto lokalite zablokovať sledovanie vašej osoby obsahom Facebooku.","informationalModalConfirmButtonText":"Prihlásiť sa","informationalModalRejectButtonText":"Prejsť späť","loginButtonText":"Prihláste sa pomocou služby Facebook","loginBodyText":"Keď použijete prihlasovanie cez Facebook, Facebook bude na lokalite sledovať vašu aktivitu.","buttonTextUnblockContent":"Odblokovať obsah Facebooku","buttonTextUnblockComment":"Odblokovať komentár na Facebooku","buttonTextUnblockComments":"Odblokovať komentáre na Facebooku","buttonTextUnblockPost":"Odblokovať príspevok na Facebooku","buttonTextUnblockVideo":"Odblokovanie videa na Facebooku","buttonTextUnblockLogin":"Odblokovať prihlásenie na Facebook","infoTitleUnblockContent":"DuckDuckGo zablokoval tento obsah, aby vás Facebook nesledoval","infoTitleUnblockComment":"DuckDuckGo zablokoval tento komentár, aby zabránil sledovaniu zo strany Facebooku","infoTitleUnblockComments":"DuckDuckGo zablokoval tieto komentáre, aby vás Facebook nesledoval","infoTitleUnblockPost":"DuckDuckGo zablokoval tento príspevok, aby vás Facebook nesledoval","infoTitleUnblockVideo":"DuckDuckGo zablokoval toto video, aby vás Facebook nesledoval","infoTextUnblockContent":"Pri načítaní stránky sme zablokovali Facebook, aby vás nesledoval. Ak tento obsah odblokujete, Facebook bude vedieť o vašej aktivite."},"shared.json":{"learnMore":"Zistite viac","readAbout":"Prečítajte si o tejto ochrane súkromia","shareFeedback":"Zdieľať spätnú väzbu"},"youtube.json":{"informationalModalMessageTitle":"Chcete povoliť všetky ukážky zo služby YouTube?","informationalModalMessageBody":"Zobrazenie ukážok umožní spoločnosti Google (ktorá vlastní YouTube) vidieť niektoré informácie o vašom zariadení, ale stále je to súkromnejšie ako prehrávanie videa.","informationalModalConfirmButtonText":"Povoliť všetky ukážky","informationalModalRejectButtonText":"Nie, ďakujem","buttonTextUnblockVideo":"Odblokovať YouTube video","infoTitleUnblockVideo":"DuckDuckGo toto video v službe YouTube zablokoval s cieľom predísť tomu, aby vás spoločnosť Google mohla sledovať","infoTextUnblockVideo":"Zablokovali sme pre spoločnosť Google (ktorá vlastní YouTube), aby vás nemohla sledovať, keď sa stránka načíta. Ak toto video odblokujete, Google bude poznať vašu aktivitu.","infoPreviewToggleText":"Ukážky sú zakázané s cieľom zvýšiť ochranu súkromia","infoPreviewToggleEnabledText":"Ukážky sú povolené","infoPreviewToggleEnabledDuckDuckGoText":"Ukážky YouTube sú v DuckDuckGo povolené.","infoPreviewInfoText":"Získajte viac informácií o DuckDuckGo, vloženej ochrane sociálnych médií"}},"sl":{"facebook.json":{"informationalModalMessageTitle":"Če se prijavite s Facebookom, vam Facebook lahko sledi","informationalModalMessageBody":"Ko ste enkrat prijavljeni, DuckDuckGo ne more blokirati Facebookove vsebine, da bi vam sledila na tem spletnem mestu.","informationalModalConfirmButtonText":"Prijava","informationalModalRejectButtonText":"Pojdi nazaj","loginButtonText":"Prijavite se s Facebookom","loginBodyText":"Če se prijavite s Facebookom, bo nato spremljal vaša dejanja na spletnem mestu.","buttonTextUnblockContent":"Odblokiraj vsebino na Facebooku","buttonTextUnblockComment":"Odblokiraj komentar na Facebooku","buttonTextUnblockComments":"Odblokiraj komentarje na Facebooku","buttonTextUnblockPost":"Odblokiraj objavo na Facebooku","buttonTextUnblockVideo":"Odblokiraj videoposnetek na Facebooku","buttonTextUnblockLogin":"Odblokiraj prijavo na Facebooku","infoTitleUnblockContent":"DuckDuckGo je blokiral to vsebino, da bi Facebooku preprečil sledenje","infoTitleUnblockComment":"DuckDuckGo je blokiral ta komentar, da bi Facebooku preprečil sledenje","infoTitleUnblockComments":"DuckDuckGo je blokiral te komentarje, da bi Facebooku preprečil sledenje","infoTitleUnblockPost":"DuckDuckGo je blokiral to objavo, da bi Facebooku preprečil sledenje","infoTitleUnblockVideo":"DuckDuckGo je blokiral ta videoposnetek, da bi Facebooku preprečil sledenje","infoTextUnblockContent":"Ko se je stran naložila, smo Facebooku preprečili, da bi vam sledil. Če to vsebino odblokirate, bo Facebook izvedel za vaša dejanja."},"shared.json":{"learnMore":"Več","readAbout":"Preberite več o tej zaščiti zasebnosti","shareFeedback":"Deli povratne informacije"},"youtube.json":{"informationalModalMessageTitle":"Želite omogočiti vse YouTubove predoglede?","informationalModalMessageBody":"Prikaz predogledov omogoča Googlu (ki je lastnik YouTuba) vpogled v nekatere podatke o napravi, vendar je še vedno bolj zasebno kot predvajanje videoposnetka.","informationalModalConfirmButtonText":"Omogoči vse predoglede","informationalModalRejectButtonText":"Ne, hvala","buttonTextUnblockVideo":"Odblokiraj videoposnetek na YouTubu","infoTitleUnblockVideo":"DuckDuckGo je blokiral ta videoposnetek v YouTubu, da bi Googlu preprečil sledenje","infoTextUnblockVideo":"Googlu (ki je lastnik YouTuba) smo preprečili, da bi vam sledil, ko se je stran naložila. Če odblokirate ta videoposnetek, bo Google izvedel za vašo dejavnost.","infoPreviewToggleText":"Predogledi so zaradi dodatne zasebnosti onemogočeni","infoPreviewToggleEnabledText":"Predogledi so omogočeni","infoPreviewToggleEnabledDuckDuckGoText":"YouTubovi predogledi so omogočeni v DuckDuckGo.","infoPreviewInfoText":"Več o vgrajeni zaščiti družbenih medijev DuckDuckGo"}},"sv":{"facebook.json":{"informationalModalMessageTitle":"Om du loggar in med Facebook kan de spåra dig","informationalModalMessageBody":"När du väl är inloggad kan DuckDuckGo inte hindra Facebooks innehåll från att spåra dig på den här webbplatsen.","informationalModalConfirmButtonText":"Logga in","informationalModalRejectButtonText":"Gå tillbaka","loginButtonText":"Logga in med Facebook","loginBodyText":"Facebook spårar din aktivitet på en webbplats om du använder det för att logga in.","buttonTextUnblockContent":"Avblockera Facebook-innehåll","buttonTextUnblockComment":"Avblockera Facebook-kommentar","buttonTextUnblockComments":"Avblockera Facebook-kommentarer","buttonTextUnblockPost":"Avblockera Facebook-inlägg","buttonTextUnblockVideo":"Avblockera Facebook-video","buttonTextUnblockLogin":"Avblockera Facebook-inloggning","infoTitleUnblockContent":"DuckDuckGo blockerade det här innehållet för att förhindra att Facebook spårar dig","infoTitleUnblockComment":"DuckDuckGo blockerade den här kommentaren för att förhindra att Facebook spårar dig","infoTitleUnblockComments":"DuckDuckGo blockerade de här kommentarerna för att förhindra att Facebook spårar dig","infoTitleUnblockPost":"DuckDuckGo blockerade det här inlägget för att förhindra att Facebook spårar dig","infoTitleUnblockVideo":"DuckDuckGo blockerade den här videon för att förhindra att Facebook spårar dig","infoTextUnblockContent":"Vi hindrade Facebook från att spåra dig när sidan lästes in. Om du avblockerar det här innehållet kommer Facebook att känna till din aktivitet."},"shared.json":{"learnMore":"Läs mer","readAbout":"Läs mer om detta integritetsskydd","shareFeedback":"Berätta vad du tycker"},"youtube.json":{"informationalModalMessageTitle":"Aktivera alla förhandsvisningar för YouTube?","informationalModalMessageBody":"Genom att visa förhandsvisningar kan Google (som äger YouTube) se en del av enhetens information, men det är ändå mer privat än att spela upp videon.","informationalModalConfirmButtonText":"Aktivera alla förhandsvisningar","informationalModalRejectButtonText":"Nej tack","buttonTextUnblockVideo":"Avblockera YouTube-video","infoTitleUnblockVideo":"DuckDuckGo blockerade den här YouTube-videon för att förhindra att Google spårar dig","infoTextUnblockVideo":"Vi hindrade Google (som äger YouTube) från att spåra dig när sidan laddades. Om du tar bort blockeringen av videon kommer Google att känna till din aktivitet.","infoPreviewToggleText":"Förhandsvisningar har inaktiverats för ytterligare integritet","infoPreviewToggleEnabledText":"Förhandsvisningar aktiverade","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-förhandsvisningar aktiverade i DuckDuckGo.","infoPreviewInfoText":"Läs mer om DuckDuckGos skydd mot inbäddade sociala medier"}},"tr":{"facebook.json":{"informationalModalMessageTitle":"Facebook ile giriş yapmak, sizi takip etmelerini sağlar","informationalModalMessageBody":"Giriş yaptıktan sonra, DuckDuckGo Facebook içeriğinin sizi bu sitede izlemesini engelleyemez.","informationalModalConfirmButtonText":"Oturum Aç","informationalModalRejectButtonText":"Geri dön","loginButtonText":"Facebook ile giriş yapın","loginBodyText":"Facebook, giriş yapmak için kullandığınızda bir sitedeki etkinliğinizi izler.","buttonTextUnblockContent":"Facebook İçeriğinin Engelini Kaldır","buttonTextUnblockComment":"Facebook Yorumunun Engelini Kaldır","buttonTextUnblockComments":"Facebook Yorumlarının Engelini Kaldır","buttonTextUnblockPost":"Facebook Gönderisinin Engelini Kaldır","buttonTextUnblockVideo":"Facebook Videosunun Engelini Kaldır","buttonTextUnblockLogin":"Facebook Girişinin Engelini Kaldır","infoTitleUnblockContent":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu içeriği engelledi","infoTitleUnblockComment":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu yorumu engelledi","infoTitleUnblockComments":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu yorumları engelledi","infoTitleUnblockPost":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu gönderiyi engelledi","infoTitleUnblockVideo":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu videoyu engelledi","infoTextUnblockContent":"Sayfa yüklendiğinde Facebook'un sizi izlemesini engelledik. Bu içeriğin engelini kaldırırsanız Facebook etkinliğinizi öğrenecektir."},"shared.json":{"learnMore":"Daha Fazla Bilgi","readAbout":"Bu gizlilik koruması hakkında bilgi edinin","shareFeedback":"Geri Bildirim Paylaş"},"youtube.json":{"informationalModalMessageTitle":"Tüm YouTube önizlemeleri etkinleştirilsin mi?","informationalModalMessageBody":"Önizlemelerin gösterilmesi Google'ın (YouTube'un sahibi) cihazınızın bazı bilgilerini görmesine izin verir, ancak yine de videoyu oynatmaktan daha özeldir.","informationalModalConfirmButtonText":"Tüm Önizlemeleri Etkinleştir","informationalModalRejectButtonText":"Hayır Teşekkürler","buttonTextUnblockVideo":"YouTube Videosunun Engelini Kaldır","infoTitleUnblockVideo":"DuckDuckGo, Google'ın sizi izlemesini önlemek için bu YouTube videosunu engelledi","infoTextUnblockVideo":"Sayfa yüklendiğinde Google'ın (YouTube'un sahibi) sizi izlemesini engelledik. Bu videonun engelini kaldırırsanız, Google etkinliğinizi öğrenecektir.","infoPreviewToggleText":"Ek gizlilik için önizlemeler devre dışı bırakıldı","infoPreviewToggleEnabledText":"Önizlemeler etkinleştirildi","infoPreviewToggleEnabledDuckDuckGoText":"DuckDuckGo'da YouTube önizlemeleri etkinleştirildi.","infoPreviewInfoText":"DuckDuckGo Yerleşik Sosyal Medya Koruması hakkında daha fazla bilgi edinin"}}}`; @@ -7178,9 +7382,10 @@ * (e.g. fonts.) * @param {import('../../content-feature.js').AssetConfig} [assets] */ - function getStyles (assets) { + function getStyles(assets) { let fontStyle = ''; - let regularFontFamily = "system, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"; + let regularFontFamily = + "system, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"; let boldFontFamily = regularFontFamily; if (assets?.regularFontUrl && assets?.boldFontUrl) { fontStyle = ` @@ -7230,8 +7435,8 @@ `, inactive: ` background-color: #666666; - ` - } + `, + }, }, lightMode: { background: ` @@ -7264,8 +7469,8 @@ `, inactive: ` background-color: #666666; - ` - } + `, + }, }, loginMode: { buttonBackground: ` @@ -7273,7 +7478,7 @@ `, buttonFont: ` color: #FFFFFF; - ` + `, }, cancelMode: { buttonBackground: ` @@ -7287,7 +7492,7 @@ `, buttonBackgroundPress: ` background: rgba(0, 0, 0, 0.18); - ` + `, }, button: ` border-radius: 8px; @@ -7672,7 +7877,7 @@ `, inactive: ` left: 1px; - ` + `, }, placeholderWrapperDiv: ` position: relative; @@ -7778,14 +7983,14 @@ `, youTubePreviewInfoText: ` color: #ABABAB; - ` - } + `, + }; } /** * @param {string} locale UI locale */ - function getConfig (locale) { + function getConfig(locale) { const allLocales = JSON.parse(localesJSON); const localeStrings = allLocales[locale] || allLocales.en; @@ -7800,199 +8005,187 @@ messageTitle: fbStrings.informationalModalMessageTitle, messageBody: fbStrings.informationalModalMessageBody, confirmButtonText: fbStrings.informationalModalConfirmButtonText, - rejectButtonText: fbStrings.informationalModalRejectButtonText + rejectButtonText: fbStrings.informationalModalRejectButtonText, }, elementData: { 'FB Like Button': { - selectors: [ - '.fb-like' - ], + selectors: ['.fb-like'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Button iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/like.php']", "iframe[src*='//www.facebook.com/v2.0/plugins/like.php']", "iframe[src*='//www.facebook.com/plugins/share_button.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/share_button.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/share_button.php']", ], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Save Button': { - selectors: [ - '.fb-save' - ], + selectors: ['.fb-save'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Share Button': { - selectors: [ - '.fb-share-button' - ], + selectors: ['.fb-share-button'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Page iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/page.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/page.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/page.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Page Div': { - selectors: [ - '.fb-page' - ], + selectors: ['.fb-page'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', - targetURL: 'https://www.facebook.com/plugins/page.php?href=data-href&tabs=data-tabs&width=data-width&height=data-height', + targetURL: + 'https://www.facebook.com/plugins/page.php?href=data-href&tabs=data-tabs&width=data-width&height=data-height', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-tabs': { - default: 'timeline' + default: 'timeline', }, 'data-height': { - default: '500' + default: '500', }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Comment iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/comment_embed.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/comment_embed.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/comment_embed.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComment, infoTitle: fbStrings.infoTitleUnblockComment, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Comments': { - selectors: [ - '.fb-comments', - 'fb\\:comments' - ], + selectors: ['.fb-comments', 'fb\\:comments'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComments, infoTitle: fbStrings.infoTitleUnblockComments, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'allowFull', - targetURL: 'https://www.facebook.com/v9.0/plugins/comments.php?href=data-href&numposts=data-numposts&sdk=joey&version=v9.0&width=data-width', + targetURL: + 'https://www.facebook.com/v9.0/plugins/comments.php?href=data-href&numposts=data-numposts&sdk=joey&version=v9.0&width=data-width', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-numposts': { - default: 10 + default: 10, }, 'data-width': { - default: '500' - } - } - } + default: '500', + }, + }, + }, }, 'FB Embedded Comment Div': { - selectors: [ - '.fb-comment-embed' - ], + selectors: ['.fb-comment-embed'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComment, infoTitle: fbStrings.infoTitleUnblockComment, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', - targetURL: 'https://www.facebook.com/v9.0/plugins/comment_embed.php?href=data-href&sdk=joey&width=data-width&include_parent=data-include-parent', + targetURL: + 'https://www.facebook.com/v9.0/plugins/comment_embed.php?href=data-href&sdk=joey&width=data-width&include_parent=data-include-parent', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' + default: '500', }, 'data-include-parent': { - default: 'false' - } + default: 'false', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Post iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/post.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/post.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/post.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockPost, infoTitle: fbStrings.infoTitleUnblockPost, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Posts Div': { - selectors: [ - '.fb-post' - ], + selectors: ['.fb-post'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockPost, infoTitle: fbStrings.infoTitleUnblockPost, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'allowFull', @@ -8000,49 +8193,47 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', unit: 'px', - fallbackAttribute: 'data-width' - } - } - } + fallbackAttribute: 'data-width', + }, + }, + }, }, 'FB Video iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/video.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/video.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/video.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockVideo, infoTitle: fbStrings.infoTitleUnblockVideo, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Video': { - selectors: [ - '.fb-video' - ], + selectors: ['.fb-video'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockVideo, infoTitle: fbStrings.infoTitleUnblockVideo, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', @@ -8050,49 +8241,47 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', unit: 'px', - fallbackAttribute: 'data-width' - } - } - } + fallbackAttribute: 'data-width', + }, + }, + }, }, 'FB Group iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/group.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/group.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/group.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Group': { - selectors: [ - '.fb-group' - ], + selectors: ['.fb-group'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', @@ -8100,49 +8289,48 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Login Button': { - selectors: [ - '.fb-login-button' - ], + selectors: ['.fb-login-button'], replaceSettings: { type: 'loginButton', icon: blockedFBLogo, buttonText: fbStrings.loginButtonText, buttonTextUnblockLogin: fbStrings.buttonTextUnblockLogin, - popupBodyText: fbStrings.loginBodyText + popupBodyText: fbStrings.loginBodyText, }, clickAction: { type: 'allowFull', - targetURL: 'https://www.facebook.com/v9.0/plugins/login_button.php?app_id=app_id_replace&auto_logout_link=false&button_type=continue_with&sdk=joey&size=large&use_continue_as=false&width=', + targetURL: + 'https://www.facebook.com/v9.0/plugins/login_button.php?app_id=app_id_replace&auto_logout_link=false&button_type=continue_with&sdk=joey&size=large&use_continue_as=false&width=', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' + default: '500', }, app_id_replace: { - default: 'null' - } - } - } - } - } + default: 'null', + }, + }, + }, + }, + }, }, Youtube: { informationalModal: { @@ -8150,7 +8338,7 @@ messageTitle: ytStrings.informationalModalMessageTitle, messageBody: ytStrings.informationalModalMessageBody, confirmButtonText: ytStrings.informationalModalConfirmButtonText, - rejectButtonText: ytStrings.informationalModalRejectButtonText + rejectButtonText: ytStrings.informationalModalRejectButtonText, }, elementData: { 'YouTube embedded video': { @@ -8162,7 +8350,7 @@ "iframe[data-src*='//youtube.com/embed']", "iframe[data-src*='//youtube-nocookie.com/embed']", "iframe[data-src*='//www.youtube.com/embed']", - "iframe[data-src*='//www.youtube-nocookie.com/embed']" + "iframe[data-src*='//www.youtube-nocookie.com/embed']", ], replaceSettings: { type: 'youtube-video', @@ -8176,13 +8364,13 @@ previewToggleEnabledDuckDuckGoText: ytStrings.infoPreviewToggleEnabledText, videoPlayIcon: { lightMode: videoPlayLight, - darkMode: videoPlayDark - } - } + darkMode: videoPlayDark, + }, + }, }, clickAction: { - type: 'youtube-video' - } + type: 'youtube-video', + }, }, 'YouTube embedded subscription button': { selectors: [ @@ -8193,24 +8381,24 @@ "iframe[data-src*='//youtube.com/subscribe_embed']", "iframe[data-src*='//youtube-nocookie.com/subscribe_embed']", "iframe[data-src*='//www.youtube.com/subscribe_embed']", - "iframe[data-src*='//www.youtube-nocookie.com/subscribe_embed']" + "iframe[data-src*='//www.youtube-nocookie.com/subscribe_embed']", ], replaceSettings: { - type: 'blank' - } - } - } - } + type: 'blank', + }, + }, + }, + }, }; - return { config, sharedStrings } + return { config, sharedStrings }; } /** * The following code is originally from https://github.com/mozilla-extensions/secure-proxy/blob/db4d1b0e2bfe0abae416bf04241916f9e4768fd2/src/commons/template.js */ class Template { - constructor (strings, values) { + constructor(strings, values) { this.values = values; this.strings = strings; } @@ -8222,35 +8410,35 @@ * The string to escape. * @return {string} The escaped string. */ - escapeXML (str) { + escapeXML(str) { const replacements = { '&': '&', '"': '"', "'": ''', '<': '<', '>': '>', - '/': '/' + '/': '/', }; - return String(str).replace(/[&"'<>/]/g, m => replacements[m]) + return String(str).replace(/[&"'<>/]/g, (m) => replacements[m]); } - potentiallyEscape (value) { + potentiallyEscape(value) { if (typeof value === 'object') { if (value instanceof Array) { - return value.map(val => this.potentiallyEscape(val)).join('') + return value.map((val) => this.potentiallyEscape(val)).join(''); } // If we are an escaped template let join call toString on it if (value instanceof Template) { - return value + return value; } - throw new Error('Unknown object to escape') + throw new Error('Unknown object to escape'); } - return this.escapeXML(value) + return this.escapeXML(value); } - toString () { + toString() { const result = []; for (const [i, string] of this.strings.entries()) { @@ -8259,33 +8447,33 @@ result.push(this.potentiallyEscape(this.values[i])); } } - return result.join('') + return result.join(''); } } - function html (strings, ...values) { - return new Template(strings, values) + function html(strings, ...values) { + return new Template(strings, values); } /** * @param {string} string * @return {Template} */ - function trustedUnsafe (string) { - return html([string]) + function trustedUnsafe(string) { + return html([string]); } /** * Use a policy if trustedTypes is available * @return {{createHTML: (s: string) => any}} */ - function createPolicy () { + function createPolicy() { if (globalThis.trustedTypes) { - return globalThis.trustedTypes?.createPolicy?.('ddg-default', { createHTML: (s) => s }) + return globalThis.trustedTypes?.createPolicy?.('ddg-default', { createHTML: (s) => s }); } return { - createHTML: (s) => s - } + createHTML: (s) => s, + }; } var cssVars = ":host {\n /* Color palette */\n --ddg-shade-06: rgba(0, 0, 0, 0.06);\n --ddg-shade-12: rgba(0, 0, 0, 0.12);\n --ddg-shade-18: rgba(0, 0, 0, 0.18);\n --ddg-shade-36: rgba(0, 0, 0, 0.36);\n --ddg-shade-84: rgba(0, 0, 0, 0.84);\n --ddg-tint-12: rgba(255, 255, 255, 0.12);\n --ddg-tint-18: rgba(255, 255, 255, 0.18);\n --ddg-tint-24: rgba(255, 255, 255, 0.24);\n --ddg-tint-84: rgba(255, 255, 255, 0.84);\n /* Tokens */\n --ddg-color-primary: #3969ef;\n --ddg-color-bg-01: #ffffff;\n --ddg-color-bg-02: #ababab;\n --ddg-color-border: var(--ddg-shade-12);\n --ddg-color-txt: var(--ddg-shade-84);\n --ddg-color-txt-link-02: #ababab;\n}\n@media (prefers-color-scheme: dark) {\n :host {\n --ddg-color-primary: #7295f6;\n --ddg-color-bg-01: #222222;\n --ddg-color-bg-02: #444444;\n --ddg-color-border: var(--ddg-tint-12);\n --ddg-color-txt: var(--ddg-tint-84);\n }\n}\n\n/* SHARED STYLES */\n/* Text Link */\n.ddg-text-link {\n line-height: 1.4;\n font-size: 14px;\n font-weight: 700;\n cursor: pointer;\n text-decoration: none;\n color: var(--ddg-color-primary);\n}\n\n/* Button */\n.DuckDuckGoButton {\n border-radius: 8px;\n padding: 8px 16px;\n border-color: var(--ddg-color-primary);\n border: none;\n min-height: 36px;\n\n position: relative;\n cursor: pointer;\n box-shadow: none;\n z-index: 2147483646;\n}\n.DuckDuckGoButton > div {\n display: flex;\n flex-direction: row;\n align-items: center;\n border: none;\n padding: 0;\n margin: 0;\n}\n.DuckDuckGoButton,\n.DuckDuckGoButton > div {\n font-size: 14px;\n font-family: DuckDuckGoPrivacyEssentialsBold;\n font-weight: 600;\n}\n.DuckDuckGoButton.tertiary {\n color: var(--ddg-color-txt);\n background-color: transparent;\n display: flex;\n justify-content: center;\n align-items: center;\n border: 1px solid var(--ddg-color-border);\n border-radius: 8px;\n}\n.DuckDuckGoButton.tertiary:hover {\n background: var(--ddg-shade-06);\n border-color: var(--ddg-shade-18);\n}\n@media (prefers-color-scheme: dark) {\n .DuckDuckGoButton.tertiary:hover {\n background: var(--ddg-tint-18);\n border-color: var(--ddg-tint-24);\n }\n}\n.DuckDuckGoButton.tertiary:active {\n background: var(--ddg-shade-12);\n border-color: var(--ddg-shade-36);\n}\n@media (prefers-color-scheme: dark) {\n .DuckDuckGoButton.tertiary:active {\n background: var(--ddg-tint-24);\n border-color: var(--ddg-tint-24);\n }\n}\n"; @@ -8323,26 +8511,26 @@ * This is currently only used in our Mobile Apps, but can be expanded in the future. */ class DDGCtlPlaceholderBlockedElement extends HTMLElement { - static CUSTOM_TAG_NAME = 'ddg-ctl-placeholder-blocked' + static CUSTOM_TAG_NAME = 'ddg-ctl-placeholder-blocked'; /** * Min height that the placeholder needs to have in order to * have enough room to display content. */ - static MIN_CONTENT_HEIGHT = 110 - static MAX_CONTENT_WIDTH_SMALL = 480 - static MAX_CONTENT_WIDTH_MEDIUM = 650 + static MIN_CONTENT_HEIGHT = 110; + static MAX_CONTENT_WIDTH_SMALL = 480; + static MAX_CONTENT_WIDTH_MEDIUM = 650; /** * Set observed attributes that will trigger attributeChangedCallback() */ - static get observedAttributes () { - return ['style'] + static get observedAttributes() { + return ['style']; } /** * Placeholder element for blocked content * @type {HTMLDivElement} */ - placeholderBlocked + placeholderBlocked; /** * Size variant of the latest calculated size of the placeholder. @@ -8350,7 +8538,7 @@ * and adapt the layout for each size. * @type {placeholderSize} */ - size = null + size = null; /** * @param {object} params - Params for building a custom element @@ -8366,7 +8554,7 @@ * @param {WithFeedbackParams=} params.withFeedback - Shows feedback link on tablet and desktop sizes, * @param {(originalElement: HTMLIFrameElement | HTMLElement, replacementElement: HTMLElement) => (e: any) => void} params.onButtonClick */ - constructor (params) { + constructor(params) { super(); this.params = params; /** @@ -8374,7 +8562,7 @@ * @type {ShadowRoot} */ const shadow = this.attachShadow({ - mode: this.params.devMode ? 'open' : 'closed' + mode: this.params.devMode ? 'open' : 'closed', }); /** @@ -8422,15 +8610,13 @@ container.classList.add('DuckDuckGoSocialContainer'); const cardClassNames = [ ['slim-card', !!useSlimCard], - ['with-feedback-link', !!withFeedback] + ['with-feedback-link', !!withFeedback], ] .map(([className, active]) => (active ? className : '')) .join(' '); // Only add a card footer if we have the toggle button to display - const cardFooterSection = withToggle - ? html` ` - : ''; + const cardFooterSection = withToggle ? html` ` : ''; const learnMoreLink = this.createLearnMoreLink(); container.innerHTML = html` @@ -8449,8 +8635,8 @@ `.toString(); - return container - } + return container; + }; /** * Creates a template string for Learn More link. @@ -8464,8 +8650,8 @@ href="https://help.duckduckgo.com/duckduckgo-help-pages/privacy/embedded-content-protection/" target="_blank" >${learnMore.learnMore}` - } + >`; + }; /** * Creates a Feedback Link container row @@ -8481,15 +8667,15 @@ `.toString(); - return container - } + return container; + }; /** * Creates a template string for a toggle button with text. */ createToggleButton = () => { const { withToggle } = this.params; - if (!withToggle) return + if (!withToggle) return; const { isActive, dataKey, label, size: toggleSize = 'md' } = withToggle; @@ -8507,8 +8693,8 @@
${label}
`; - return toggleButton - } + return toggleButton; + }; /** * @@ -8518,19 +8704,15 @@ setupEventListeners = (containerElement, feedbackLink) => { const { withToggle, withFeedback, originalElement, onButtonClick } = this.params; - containerElement - .querySelector('button.ddg-ctl-unblock-btn') - ?.addEventListener('click', onButtonClick(originalElement, this)); + containerElement.querySelector('button.ddg-ctl-unblock-btn')?.addEventListener('click', onButtonClick(originalElement, this)); if (withToggle) { - containerElement - .querySelector('.ddg-toggle-button-container') - ?.addEventListener('click', withToggle.onClick); + containerElement.querySelector('.ddg-toggle-button-container')?.addEventListener('click', withToggle.onClick); } if (withFeedback && feedbackLink) { feedbackLink.querySelector('.ddg-ctl-feedback-link')?.addEventListener('click', withFeedback.onClick); } - } + }; /** * Use JS to calculate the width and height of the root element placeholder. We could use a CSS Container Query, but full @@ -8561,14 +8743,14 @@ this.placeholderBlocked.classList.add(newSize); this.size = newSize; } - } + }; /** * Web Component lifecycle function. * When element is first added to the DOM, trigger this callback and * update the element CSS size class. */ - connectedCallback () { + connectedCallback() { this.updatePlaceholderSize(); } @@ -8581,7 +8763,7 @@ * @param {*} _ Attribute old value, ignored * @param {*} newValue Attribute new value */ - attributeChangedCallback (attr, _, newValue) { + attributeChangedCallback(attr, _, newValue) { if (attr === 'style') { this.placeholderBlocked[attr].cssText = newValue; this.updatePlaceholderSize(); @@ -8608,7 +8790,7 @@ * Placeholder container element for blocked login button * @type {HTMLDivElement} */ - #element + #element; /** * @param {object} params - Params for building a custom element with @@ -8622,7 +8804,7 @@ * @param {LearnMoreParams} params.learnMore - Localized strings for "Learn More" link. * @param {(originalElement: HTMLIFrameElement | HTMLElement, replacementElement: HTMLElement) => (e: any) => void} params.onClick */ - constructor (params) { + constructor(params) { this.params = params; /** @@ -8636,7 +8818,7 @@ * @type {ShadowRoot} */ const shadow = this.element.attachShadow({ - mode: this.params.devMode ? 'open' : 'closed' + mode: this.params.devMode ? 'open' : 'closed', }); /** @@ -8667,14 +8849,14 @@ /** * @returns {HTMLDivElement} */ - get element () { - return this.#element + get element() { + return this.#element; } /** * @param {HTMLDivElement} el - New placeholder element */ - set element (el) { + set element(el) { this.#element = el; } @@ -8684,7 +8866,7 @@ * proceed. * @returns {HTMLDivElement} */ - _createLoginButton () { + _createLoginButton() { const { label, hoverText, logoIcon, learnMore } = this.params; const { popoverStyle, arrowStyle } = this._calculatePopoverPosition(); @@ -8728,7 +8910,7 @@ `.toString(); - return container + return container; } /** @@ -8741,7 +8923,7 @@ * arrowStyle: string, // CSS styles to be applied in the Popover arrow * }} */ - _calculatePopoverPosition () { + _calculatePopoverPosition() { const { originalElement } = this.params; const rect = originalElement.getBoundingClientRect(); const textBubbleWidth = 360; // Should match the width rule in .ddg-popover @@ -8768,19 +8950,17 @@ arrowStyle = `left: ${arrowDefaultLocationPercent}%;`; } - return { popoverStyle, arrowStyle } + return { popoverStyle, arrowStyle }; } /** * * @param {HTMLElement} loginButton */ - _setupEventListeners (loginButton) { + _setupEventListeners(loginButton) { const { originalElement, onClick } = this.params; - loginButton - .querySelector('.ddg-ctl-fb-login-btn') - ?.addEventListener('click', onClick(originalElement, this.element)); + loginButton.querySelector('.ddg-ctl-fb-login-btn')?.addEventListener('click', onClick(originalElement, this.element)); } } @@ -8788,7 +8968,7 @@ * Register custom elements in this wrapper function to be called only when we need to * and also to allow remote-config later if needed. */ - function registerCustomElements$1 () { + function registerCustomElements$1() { if (!customElements.get(DDGCtlPlaceholderBlockedElement.CUSTOM_TAG_NAME)) { customElements.define(DDGCtlPlaceholderBlockedElement.CUSTOM_TAG_NAME, DDGCtlPlaceholderBlockedElement); } @@ -8844,7 +9024,7 @@ // finished its work, enough that it's now safe to replace elements with // placeholders. let readyToDisplayPlaceholdersResolver; - const readyToDisplayPlaceholders = new Promise(resolve => { + const readyToDisplayPlaceholders = new Promise((resolve) => { readyToDisplayPlaceholdersResolver = resolve; }); @@ -8852,7 +9032,9 @@ // readyToDisplayPlaceholders has resolved). Wait for this before sending // essential messages to surrogate scripts. let afterPageLoadResolver; - const afterPageLoad = new Promise(resolve => { afterPageLoadResolver = resolve; }); + const afterPageLoad = new Promise((resolve) => { + afterPageLoadResolver = resolve; + }); // Messaging layer for Click to Load. The messaging instance is initialized in // ClickToLoad.init() and updated here to be used outside ClickToLoad class @@ -8865,15 +9047,15 @@ /** * @return {import("@duckduckgo/messaging").Messaging} */ - get messaging () { - if (!_messagingModuleScope) throw new Error('Messaging not initialized') - return _messagingModuleScope + get messaging() { + if (!_messagingModuleScope) throw new Error('Messaging not initialized'); + return _messagingModuleScope; }, - addDebugFlag () { - if (!_addDebugFlag) throw new Error('addDebugFlag not initialized') - return _addDebugFlag() - } + addDebugFlag() { + if (!_addDebugFlag) throw new Error('addDebugFlag not initialized'); + return _addDebugFlag(); + }, }; /********************************************************* @@ -8890,7 +9072,7 @@ * @param {import('../utils').Platform} platform * The platform where Click to Load and the Duck Widget is running on (ie Extension, Android App, etc) */ - constructor (widgetData, originalElement, entity, platform) { + constructor(widgetData, originalElement, entity, platform) { this.clickAction = { ...widgetData.clickAction }; // shallow copy this.replaceSettings = widgetData.replaceSettings; this.originalElement = originalElement; @@ -8911,17 +9093,15 @@ * @param {EventTarget} eventTarget * @param {string} eventName */ - dispatchEvent (eventTarget, eventName) { + dispatchEvent(eventTarget, eventName) { eventTarget.dispatchEvent( - createCustomEvent( - eventName, { - detail: { - entity: this.entity, - replaceSettings: this.replaceSettings, - widgetID: this.widgetID - } - } - ) + createCustomEvent(eventName, { + detail: { + entity: this.entity, + replaceSettings: this.replaceSettings, + widgetID: this.widgetID, + }, + }), ); } @@ -8930,9 +9110,9 @@ * clickAction.urlDataAttributesToPreserve) and store those in * this.dataElement. */ - gatherDataElements () { + gatherDataElements() { if (!this.clickAction.urlDataAttributesToPreserve) { - return + return; } for (const [attrName, attrSettings] of Object.entries(this.clickAction.urlDataAttributesToPreserve)) { let value = this.originalElement.getAttribute(attrName); @@ -8947,16 +9127,15 @@ if (attrName === 'data-width') { const windowWidth = window.innerWidth; const { parentElement } = this.originalElement; - const parentStyles = parentElement - ? window.getComputedStyle(parentElement) - : null; + const parentStyles = parentElement ? window.getComputedStyle(parentElement) : null; let parentInnerWidth = null; // We want to calculate the inner width of the parent element as the iframe, when added back, // should not be bigger than the space available in the parent element. There is no straightforward way of // doing this. We need to get the parent's .clientWidth and remove the paddings size from it. if (parentElement && parentStyles && parentStyles.display !== 'inline') { - parentInnerWidth = parentElement.clientWidth - parseFloat(parentStyles.paddingLeft) - parseFloat(parentStyles.paddingRight); + parentInnerWidth = + parentElement.clientWidth - parseFloat(parentStyles.paddingLeft) - parseFloat(parentStyles.paddingRight); } if (parentInnerWidth && parentInnerWidth < windowWidth) { @@ -8979,26 +9158,26 @@ * Load placeholder has been clicked by the user. * @returns {string} */ - getTargetURL () { + getTargetURL() { // Copying over data fields should be done lazily, since some required data may not be // captured until after page scripts run. this.copySocialDataFields(); - return this.clickAction.targetURL + return this.clickAction.targetURL; } /** * Determines which display mode the placeholder element should render in. * @returns {displayMode} */ - getMode () { + getMode() { // Login buttons are always the login style types if (this.replaceSettings.type === 'loginButton') { - return 'loginMode' + return 'loginMode'; } if (window?.matchMedia('(prefers-color-scheme: dark)')?.matches) { - return 'darkMode' + return 'darkMode'; } - return 'lightMode' + return 'lightMode'; } /** @@ -9007,7 +9186,7 @@ * * @returns {string} */ - getStyle () { + getStyle() { let styleString = 'border: none;'; if (this.clickAction.styleDataAttributes) { @@ -9029,7 +9208,7 @@ } } - return styleString + return styleString; } /** @@ -9037,9 +9216,9 @@ * placeholder element styling, and when restoring the original tracking * element. */ - copySocialDataFields () { + copySocialDataFields() { if (!this.clickAction.urlDataAttributesToPreserve) { - return + return; } // App ID may be set by client scripts, and is required for some elements. @@ -9051,7 +9230,7 @@ let attrValue = this.dataElements[key]; if (!attrValue) { - continue + continue; } // The URL for Facebook videos are specified as the data-href @@ -9062,10 +9241,7 @@ attrValue = window.location.protocol + attrValue; } - this.clickAction.targetURL = - this.clickAction.targetURL.replace( - key, encodeURIComponent(attrValue) - ); + this.clickAction.targetURL = this.clickAction.targetURL.replace(key, encodeURIComponent(attrValue)); } } @@ -9074,13 +9250,13 @@ * * @returns {HTMLIFrameElement} */ - createFBIFrame () { + createFBIFrame() { const frame = document.createElement('iframe'); frame.setAttribute('src', this.getTargetURL()); frame.setAttribute('style', this.getStyle()); - return frame + return frame; } /** @@ -9091,11 +9267,11 @@ * @returns {EventListener?} onError * Function to be called if the video fails to load. */ - adjustYouTubeVideoElement (videoElement) { + adjustYouTubeVideoElement(videoElement) { let onError = null; if (!videoElement.src) { - return onError + return onError; } const url = new URL(videoElement.src); const { hostname: originalHostname } = url; @@ -9118,7 +9294,7 @@ // Configure auto-play correctly depending on if the video's preview // loaded, otherwise it doesn't allow autoplay. let allowString = videoElement.getAttribute('allow') || ''; - const allowed = new Set(allowString.split(';').map(s => s.trim())); + const allowed = new Set(allowString.split(';').map((s) => s.trim())); if (this.autoplay) { allowed.add('autoplay'); url.searchParams.set('autoplay', '1'); @@ -9130,7 +9306,7 @@ videoElement.setAttribute('allow', allowString); videoElement.src = url.href; - return onError + return onError; } /** @@ -9144,8 +9320,8 @@ * @returns {Promise} * Promise that resolves when the fade in/out is complete. */ - fadeElement (element, interval, fadeIn) { - return new Promise(resolve => { + fadeElement(element, interval, fadeIn) { + return new Promise((resolve) => { let opacity = fadeIn ? 0 : 1; const originStyle = element.style.cssText; const fadeOut = setInterval(function () { @@ -9156,7 +9332,7 @@ resolve(); } }, interval); - }) + }); } /** @@ -9166,8 +9342,8 @@ * @returns {Promise} * Promise that resolves when the fade out is complete. */ - fadeOutElement (element) { - return this.fadeElement(element, 10, false) + fadeOutElement(element) { + return this.fadeElement(element, 10, false); } /** @@ -9177,8 +9353,8 @@ * @returns {Promise} * Promise that resolves when the fade in is complete. */ - fadeInElement (element) { - return this.fadeElement(element, 10, true) + fadeInElement(element) { + return this.fadeElement(element, 10, true); } /** @@ -9190,9 +9366,9 @@ * @param {HTMLElement} replacementElement * The placeholder element. */ - clickFunction (originalElement, replacementElement) { + clickFunction(originalElement, replacementElement) { let clicked = false; - const handleClick = e => { + const handleClick = (e) => { // Ensure that the click is created by a user event & prevent double clicks from adding more animations if (e.isTrusted && !clicked) { e.stopPropagation(); @@ -9210,21 +9386,21 @@ unblockClickToLoadContent({ entity: this.entity, action, isLogin, isSurrogateLogin }).then((response) => { // If user rejected confirmation modal and content was not unblocked, inform surrogate and stop. if (response && response.type === 'ddg-ctp-user-cancel') { - return abortSurrogateConfirmation(this.entity) + return abortSurrogateConfirmation(this.entity); } const parent = replacementElement.parentNode; // The placeholder was removed from the DOM while we loaded // the original content, give up. - if (!parent) return + if (!parent) return; // If we allow everything when this element is clicked, // notify surrogate to enable SDK and replace original element. if (this.clickAction.type === 'allowFull') { parent.replaceChild(originalElement, replacementElement); this.dispatchEvent(window, 'ddg-ctp-load-sdk'); - return + return; } // Create a container for the new FB element const fbContainer = document.createElement('div'); @@ -9259,16 +9435,16 @@ let fbElement; let onError = null; switch (this.clickAction.type) { - case 'iFrame': - fbElement = this.createFBIFrame(); - break - case 'youtube-video': - onError = this.adjustYouTubeVideoElement(originalElement); - fbElement = originalElement; - break - default: - fbElement = originalElement; - break + case 'iFrame': + fbElement = this.createFBIFrame(); + break; + case 'youtube-video': + onError = this.adjustYouTubeVideoElement(originalElement); + fbElement = originalElement; + break; + default: + fbElement = originalElement; + break; } // Modify the overlay to include a Facebook iFrame, which @@ -9277,14 +9453,18 @@ parent.replaceChild(fbContainer, replacementElement); fbContainer.appendChild(replacementElement); fadeIn.appendChild(fbElement); - fbElement.addEventListener('load', async () => { - await this.fadeOutElement(replacementElement); - fbContainer.replaceWith(fbElement); - this.dispatchEvent(fbElement, 'ddg-ctp-placeholder-clicked'); - await this.fadeInElement(fadeIn); - // Focus on new element for screen readers. - fbElement.focus(); - }, { once: true }); + fbElement.addEventListener( + 'load', + async () => { + await this.fadeOutElement(replacementElement); + fbContainer.replaceWith(fbElement); + this.dispatchEvent(fbElement, 'ddg-ctp-placeholder-clicked'); + await this.fadeInElement(fadeIn); + // Focus on new element for screen readers. + fbElement.focus(); + }, + { once: true }, + ); // Note: This event only fires on Firefox, on Chrome the frame's // load event will always fire. if (onError) { @@ -9295,19 +9475,17 @@ }; // If this is a login button, show modal if needed if (this.replaceSettings.type === 'loginButton' && entityData[this.entity].shouldShowLoginModal) { - return e => { + return (e) => { // Even if the user cancels the login attempt, consider Facebook Click to // Load to have been active on the page if the user reports the page as broken. if (this.entity === 'Facebook, Inc.') { notifyFacebookLogin(); } - handleUnblockConfirmation( - this.platform.name, this.entity, handleClick, e - ); - } + handleUnblockConfirmation(this.platform.name, this.entity, handleClick, e); + }; } - return handleClick + return handleClick; } /** @@ -9315,8 +9493,8 @@ * return if the new layout using Web Components is supported or not. * @returns {boolean} */ - shouldUseCustomElement () { - return platformsWithWebComponentsEnabled.includes(this.platform.name) + shouldUseCustomElement() { + return platformsWithWebComponentsEnabled.includes(this.platform.name); } /** @@ -9325,8 +9503,8 @@ * define which layout to use between Mobile and Desktop Platforms variations. * @returns {boolean} */ - isMobilePlatform () { - return mobilePlatforms.includes(this.platform.name) + isMobilePlatform() { + return mobilePlatforms.includes(this.platform.name); } } @@ -9360,7 +9538,7 @@ * @param {HTMLElement} placeholderElement * The placeholder element that should be shown instead. */ - function replaceTrackingElement (widget, trackingElement, placeholderElement) { + function replaceTrackingElement(widget, trackingElement, placeholderElement) { // In some situations (e.g. YouTube Click to Load previews are // enabled/disabled), a second placeholder will be shown for a tracking // element. @@ -9372,16 +9550,11 @@ // First hide the element, since we need to keep it in the DOM until the // events have been dispatched. - const originalDisplay = [ - elementToReplace.style.getPropertyValue('display'), - elementToReplace.style.getPropertyPriority('display') - ]; + const originalDisplay = [elementToReplace.style.getPropertyValue('display'), elementToReplace.style.getPropertyPriority('display')]; elementToReplace.style.setProperty('display', 'none', 'important'); // Add the placeholder element to the page. - elementToReplace.parentElement.insertBefore( - placeholderElement, elementToReplace - ); + elementToReplace.parentElement.insertBefore(placeholderElement, elementToReplace); // While the placeholder is shown (and original element hidden) // synchronously, the events are dispatched (and original element removed @@ -9408,7 +9581,7 @@ * @param {HTMLIFrameElement} trackingElement * The tracking element on the page that should be replaced with a placeholder. */ - function createPlaceholderElementAndReplace (widget, trackingElement) { + function createPlaceholderElementAndReplace(widget, trackingElement) { if (widget.replaceSettings.type === 'blank') { replaceTrackingElement(widget, trackingElement, document.createElement('div')); } @@ -9423,19 +9596,23 @@ hoverText: widget.replaceSettings.popupBodyText, logoIcon: facebookLogo, originalElement: trackingElement, - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - onClick: widget.clickFunction.bind(widget) + onClick: widget.clickFunction.bind(widget), }).element; facebookLoginButton.classList.add('fb-login-button', 'FacebookLogin__button'); facebookLoginButton.appendChild(makeFontFaceStyleElement()); replaceTrackingElement(widget, trackingElement, facebookLoginButton); } else { const { button, container } = makeLoginButton( - widget.replaceSettings.buttonText, widget.getMode(), - widget.replaceSettings.popupBodyText, icon, trackingElement + widget.replaceSettings.buttonText, + widget.getMode(), + widget.replaceSettings.popupBodyText, + icon, + trackingElement, ); button.addEventListener('click', widget.clickFunction(trackingElement, container)); replaceTrackingElement(widget, trackingElement, container); @@ -9459,11 +9636,12 @@ unblockBtnText: widget.replaceSettings.buttonText, // Unblock button text useSlimCard: false, // Flag for using less padding on card (ie YT CTL on mobile) originalElement: trackingElement, // The original element this placeholder is replacing. - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - onButtonClick: widget.clickFunction.bind(widget) + onButtonClick: widget.clickFunction.bind(widget), }); mobileBlockedPlaceholder.appendChild(makeFontFaceStyleElement()); @@ -9473,9 +9651,7 @@ const icon = widget.replaceSettings.icon; const button = makeButton(widget.replaceSettings.buttonText, widget.getMode()); const textButton = makeTextButton(widget.replaceSettings.buttonText, widget.getMode()); - const { contentBlock, shadowRoot } = createContentBlock( - widget, button, textButton, icon - ); + const { contentBlock, shadowRoot } = createContentBlock(widget, button, textButton, icon); button.addEventListener('click', widget.clickFunction(trackingElement, contentBlock)); textButton.addEventListener('click', widget.clickFunction(trackingElement, contentBlock)); @@ -9492,13 +9668,10 @@ // Subscribe to changes to youtubePreviewsEnabled setting // and update the CTL state - ctl.messaging.subscribe( - 'setYoutubePreviewsEnabled', - ({ value }) => { - isYoutubePreviewsEnabled = value; - replaceYouTubeCTL(trackingElement, widget); - } - ); + ctl.messaging.subscribe('setYoutubePreviewsEnabled', ({ value }) => { + isYoutubePreviewsEnabled = value; + replaceYouTubeCTL(trackingElement, widget); + }); } } @@ -9508,10 +9681,10 @@ * @param {DuckWidget} widget * The CTL 'widget' associated with the tracking element. */ - function replaceYouTubeCTL (trackingElement, widget) { + function replaceYouTubeCTL(trackingElement, widget) { // Skip replacing tracking element if it has already been unblocked if (widget.isUnblocked) { - return + return; } if (isYoutubePreviewsEnabled === true) { @@ -9539,22 +9712,24 @@ unblockBtnText: widget.replaceSettings.buttonText, // Unblock button text useSlimCard: true, // Flag for using less padding on card (ie YT CTL on mobile) originalElement: trackingElement, // The original element this placeholder is replacing. - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - withToggle: { // Toggle config to be displayed in the bottom of the placeholder + withToggle: { + // Toggle config to be displayed in the bottom of the placeholder isActive: false, // Toggle state dataKey: 'yt-preview-toggle', // data-key attribute for button label: widget.replaceSettings.previewToggleText, // Text to be presented with toggle size: widget.isMobilePlatform() ? 'lg' : 'md', - onClick: () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }) // Toggle click callback + onClick: () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), // Toggle click callback }, withFeedback: { label: sharedStrings.shareFeedback, - onClick: () => openShareFeedbackPage() + onClick: () => openShareFeedbackPage(), }, - onButtonClick: widget.clickFunction.bind(widget) + onButtonClick: widget.clickFunction.bind(widget), }); mobileBlockedPlaceholderElement.appendChild(makeFontFaceStyleElement()); mobileBlockedPlaceholderElement.id = trackingElement.id; @@ -9579,9 +9754,9 @@ * @param {ShadowRoot?} shadowRoot * @param {HTMLElement} placeholder Placeholder for tracking element */ - function showExtraUnblockIfShortPlaceholder (shadowRoot, placeholder) { + function showExtraUnblockIfShortPlaceholder(shadowRoot, placeholder) { if (!placeholder.parentElement) { - return + return; } const parentStyles = window.getComputedStyle(placeholder.parentElement); // Inline elements, like span or p, don't have a height value that we can use because they're @@ -9589,15 +9764,12 @@ // parents, it might be necessary to traverse up the DOM tree until we find the nearest non // "inline" parent to get a reliable height for this check. if (parentStyles.display === 'inline') { - return + return; } const { height: placeholderHeight } = placeholder.getBoundingClientRect(); const { height: parentHeight } = placeholder.parentElement.getBoundingClientRect(); - if ( - (placeholderHeight > 0 && placeholderHeight <= 200) || - (parentHeight > 0 && parentHeight <= 230) - ) { + if ((placeholderHeight > 0 && placeholderHeight <= 200) || (parentHeight > 0 && parentHeight <= 230)) { if (shadowRoot) { /** @type {HTMLElement?} */ const titleRowTextButton = shadowRoot.querySelector(`#${titleID + 'TextButton'}`); @@ -9626,11 +9798,10 @@ * Maximum placeholder width (in pixels) for the placeholder to be considered * narrow. */ - function hideInfoTextIfNarrowPlaceholder (shadowRoot, placeholder, narrowWidth) { + function hideInfoTextIfNarrowPlaceholder(shadowRoot, placeholder, narrowWidth) { const { width: placeholderWidth } = placeholder.getBoundingClientRect(); if (placeholderWidth > 0 && placeholderWidth <= narrowWidth) { - const buttonContainer = - shadowRoot.querySelector('.DuckDuckGoButton.primary')?.parentElement; + const buttonContainer = shadowRoot.querySelector('.DuckDuckGoButton.primary')?.parentElement; const contentTitle = shadowRoot.getElementById('contentTitle'); const infoText = shadowRoot.getElementById('infoText'); /** @type {HTMLElement?} */ @@ -9638,7 +9809,7 @@ // These elements will exist, but this check keeps TypeScript happy. if (!buttonContainer || !contentTitle || !infoText || !learnMoreLink) { - return + return; } // Remove the information text. @@ -9682,8 +9853,8 @@ * @see {@link ddg-ctp-unblockClickToLoadContent-complete} for the response handler. * @returns {Promise} */ - function unblockClickToLoadContent (message) { - return ctl.messaging.request('unblockClickToLoadContent', message) + function unblockClickToLoadContent(message) { + return ctl.messaging.request('unblockClickToLoadContent', message); } /** @@ -9700,14 +9871,14 @@ * @param {...any} acceptFunctionParams * The parameters passed to acceptFunction when it is called. */ - function handleUnblockConfirmation (platformName, entity, acceptFunction, ...acceptFunctionParams) { + function handleUnblockConfirmation(platformName, entity, acceptFunction, ...acceptFunctionParams) { // In our mobile platforms, we want to show a native UI to request user unblock // confirmation. In these cases we send directly the unblock request to the platform // and the platform chooses how to best handle it. if (platformsWithNativeModalSupport.includes(platformName)) { acceptFunction(...acceptFunctionParams); - // By default, for other platforms (ie Extension), we show a web modal with a - // confirmation request to the user before we proceed to unblock the content. + // By default, for other platforms (ie Extension), we show a web modal with a + // confirmation request to the user before we proceed to unblock the content. } else { makeModal(entity, acceptFunction, ...acceptFunctionParams); } @@ -9718,7 +9889,7 @@ * Facebook Click to Load login flow had started if the user should then report * the website as broken. */ - function notifyFacebookLogin () { + function notifyFacebookLogin() { ctl.addDebugFlag(); ctl.messaging.notify('updateFacebookCTLBreakageFlags', { ctlFacebookLogin: true }); } @@ -9729,7 +9900,7 @@ * shown. * @param {string} entity */ - async function runLogin (entity) { + async function runLogin(entity) { if (entity === 'Facebook, Inc.') { notifyFacebookLogin(); } @@ -9738,15 +9909,15 @@ const response = await unblockClickToLoadContent({ entity, action, isLogin: true, isSurrogateLogin: true }); // If user rejected confirmation modal and content was not unblocked, inform surrogate and stop. if (response && response.type === 'ddg-ctp-user-cancel') { - return abortSurrogateConfirmation(this.entity) + return abortSurrogateConfirmation(this.entity); } // Communicate with surrogate to run login originalWindowDispatchEvent( createCustomEvent('ddg-ctp-run-login', { detail: { - entity - } - }) + entity, + }, + }), ); } @@ -9755,17 +9926,17 @@ * Called after the user cancel from a warning dialog. * @param {string} entity */ - function abortSurrogateConfirmation (entity) { + function abortSurrogateConfirmation(entity) { originalWindowDispatchEvent( createCustomEvent('ddg-ctp-cancel-modal', { detail: { - entity - } - }) + entity, + }, + }), ); } - function openShareFeedbackPage () { + function openShareFeedbackPage() { ctl.messaging.notify('openShareFeedbackPage'); } @@ -9778,7 +9949,7 @@ * @param {displayMode} [mode='lightMode'] * @returns {HTMLAnchorElement} */ - function getLearnMoreLink (mode = 'lightMode') { + function getLearnMoreLink(mode = 'lightMode') { const linkElement = document.createElement('a'); linkElement.style.cssText = styles.generalLink + styles[mode].linkFont; linkElement.ariaLabel = sharedStrings.readAbout; @@ -9786,7 +9957,7 @@ linkElement.target = '_blank'; linkElement.textContent = sharedStrings.learnMore; linkElement.id = 'learnMoreLink'; - return linkElement + return linkElement; } /** @@ -9794,10 +9965,9 @@ * @param {HTMLElement} sourceElement * @param {HTMLElement} targetElement */ - function resizeElementToMatch (sourceElement, targetElement) { + function resizeElementToMatch(sourceElement, targetElement) { const computedStyle = window.getComputedStyle(sourceElement); - const stylesToCopy = ['position', 'top', 'bottom', 'left', 'right', - 'transform', 'margin']; + const stylesToCopy = ['position', 'top', 'bottom', 'left', 'right', 'transform', 'margin']; // It's apparently preferable to use the source element's size relative to // the current viewport, when resizing the target element. However, the @@ -9834,13 +10004,13 @@ * to be attached to DDG wrapper elements * @returns HTMLStyleElement */ - function makeFontFaceStyleElement () { + function makeFontFaceStyleElement() { // Put our custom font-faces inside the wrapper element, since // @font-face does not work inside a shadowRoot. // See https://github.com/mdn/interactive-examples/issues/887. const fontFaceStyleElement = document.createElement('style'); fontFaceStyleElement.textContent = styles.fontStyle; - return fontFaceStyleElement + return fontFaceStyleElement; } /** @@ -9850,7 +10020,7 @@ * @param {displayMode} [mode='lightMode'] * @returns {{wrapperClass: string, styleElement: HTMLStyleElement; }} */ - function makeBaseStyleElement (mode = 'lightMode') { + function makeBaseStyleElement(mode = 'lightMode') { // Style element includes our font & overwrites page styles const styleElement = document.createElement('style'); const wrapperClass = 'DuckDuckGoSocialContainer'; @@ -9894,7 +10064,7 @@ ${styles.cancelMode.buttonBackgroundPress} } `; - return { wrapperClass, styleElement } + return { wrapperClass, styleElement }; } /** @@ -9904,11 +10074,11 @@ * @param {displayMode} mode * @returns {HTMLAnchorElement} */ - function makeTextButton (linkText, mode = 'lightMode') { + function makeTextButton(linkText, mode = 'lightMode') { const linkElement = document.createElement('a'); linkElement.style.cssText = styles.headerLink + styles[mode].linkFont; linkElement.textContent = linkText; - return linkElement + return linkElement; } /** @@ -9921,7 +10091,7 @@ * action. * @returns {HTMLButtonElement} Button element */ - function makeButton (buttonText, mode = 'lightMode') { + function makeButton(buttonText, mode = 'lightMode') { const button = document.createElement('button'); button.classList.add('DuckDuckGoButton'); button.classList.add(mode === 'cancelMode' ? 'secondary' : 'primary'); @@ -9930,7 +10100,7 @@ textContainer.textContent = buttonText; button.appendChild(textContainer); } - return button + return button; } /** @@ -9944,7 +10114,7 @@ * Value to assign to the button's 'data-key' attribute. * @returns {HTMLButtonElement} */ - function makeToggleButton (mode, isActive = false, classNames = '', dataKey = '') { + function makeToggleButton(mode, isActive = false, classNames = '', dataKey = '') { const toggleButton = document.createElement('button'); toggleButton.className = classNames; toggleButton.style.cssText = styles.toggleButton; @@ -9955,17 +10125,15 @@ const activeKey = isActive ? 'active' : 'inactive'; const toggleBg = document.createElement('div'); - toggleBg.style.cssText = - styles.toggleButtonBg + styles[mode].toggleButtonBgState[activeKey]; + toggleBg.style.cssText = styles.toggleButtonBg + styles[mode].toggleButtonBgState[activeKey]; const toggleKnob = document.createElement('div'); - toggleKnob.style.cssText = - styles.toggleButtonKnob + styles.toggleButtonKnobState[activeKey]; + toggleKnob.style.cssText = styles.toggleButtonKnob + styles.toggleButtonKnobState[activeKey]; toggleButton.appendChild(toggleBg); toggleButton.appendChild(toggleKnob); - return toggleButton + return toggleButton; } /** @@ -9984,7 +10152,7 @@ * Value to assign to the button's 'data-key' attribute. * @returns {HTMLDivElement} */ - function makeToggleButtonWithText (text, mode, isActive = false, toggleClassNames = '', textCssStyles = '', dataKey = '') { + function makeToggleButtonWithText(text, mode, isActive = false, toggleClassNames = '', textCssStyles = '', dataKey = '') { const wrapper = document.createElement('div'); wrapper.style.cssText = styles.toggleButtonWrapper; @@ -9996,27 +10164,27 @@ wrapper.appendChild(toggleButton); wrapper.appendChild(textDiv); - return wrapper + return wrapper; } /** * Create the default block symbol, for when the image isn't available. * @returns {HTMLDivElement} */ - function makeDefaultBlockIcon () { + function makeDefaultBlockIcon() { const blockedIcon = document.createElement('div'); const dash = document.createElement('div'); blockedIcon.appendChild(dash); blockedIcon.style.cssText = styles.circle; dash.style.cssText = styles.rectangle; - return blockedIcon + return blockedIcon; } /** * Creates a share feedback link element. * @returns {HTMLAnchorElement} */ - function makeShareFeedbackLink () { + function makeShareFeedbackLink() { const feedbackLink = document.createElement('a'); feedbackLink.style.cssText = styles.feedbackLink; feedbackLink.target = '_blank'; @@ -10028,21 +10196,21 @@ openShareFeedbackPage(); }); - return feedbackLink + return feedbackLink; } /** * Creates a share feedback link element, wrapped in a styled div. * @returns {HTMLDivElement} */ - function makeShareFeedbackRow () { + function makeShareFeedbackRow() { const feedbackRow = document.createElement('div'); feedbackRow.style.cssText = styles.feedbackRow; const feedbackLink = makeShareFeedbackLink(); feedbackRow.appendChild(feedbackLink); - return feedbackRow + return feedbackRow; } /** @@ -10062,7 +10230,7 @@ * expected to do that. * @returns {{ container: HTMLDivElement, button: HTMLButtonElement }} */ - function makeLoginButton (buttonText, mode, hoverTextBody, icon, originalElement) { + function makeLoginButton(buttonText, mode, hoverTextBody, icon, originalElement) { const container = document.createElement('div'); container.style.cssText = 'position: relative;'; container.appendChild(makeFontFaceStyleElement()); @@ -10126,14 +10294,14 @@ if (rect.left < styles.textBubbleLeftShift) { const leftShift = -rect.left + 10; // 10px away from edge of the screen hoverBox.style.cssText += `left: ${leftShift}px;`; - const change = (1 - (rect.left / styles.textBubbleLeftShift)) * (100 - styles.arrowDefaultLocationPercent); + const change = (1 - rect.left / styles.textBubbleLeftShift) * (100 - styles.arrowDefaultLocationPercent); arrow.style.cssText += `left: ${Math.max(10, styles.arrowDefaultLocationPercent - change)}%;`; } else if (rect.left + styles.textBubbleWidth - styles.textBubbleLeftShift > window.innerWidth) { const rightShift = rect.left + styles.textBubbleWidth - styles.textBubbleLeftShift; const diff = Math.min(rightShift - window.innerWidth, styles.textBubbleLeftShift); const rightMargin = 20; // Add some margin to the page, so scrollbar doesn't overlap. hoverBox.style.cssText += `left: -${styles.textBubbleLeftShift + diff + rightMargin}px;`; - const change = ((diff / styles.textBubbleLeftShift)) * (100 - styles.arrowDefaultLocationPercent); + const change = (diff / styles.textBubbleLeftShift) * (100 - styles.arrowDefaultLocationPercent); arrow.style.cssText += `left: ${Math.max(10, styles.arrowDefaultLocationPercent + change)}%;`; } else { hoverBox.style.cssText += `left: -${styles.textBubbleLeftShift}px;`; @@ -10142,8 +10310,8 @@ return { button, - container - } + container, + }; } /** @@ -10158,7 +10326,7 @@ * The parameters passed to acceptFunction when it is called. * TODO: Have the caller bind these arguments to the function instead. */ - function makeModal (entity, acceptFunction, ...acceptFunctionParams) { + function makeModal(entity, acceptFunction, ...acceptFunctionParams) { const icon = entityData[entity].modalIcon; const modalContainer = document.createElement('div'); @@ -10216,7 +10384,7 @@ const allowButton = makeButton(entityData[entity].modalAcceptText, 'lightMode'); allowButton.style.cssText += styles.modalButton + 'margin-bottom: 8px;'; allowButton.setAttribute('data-key', 'allow'); - allowButton.addEventListener('click', function doLogin () { + allowButton.addEventListener('click', function doLogin() { acceptFunction(...acceptFunctionParams); document.body.removeChild(modalContainer); }); @@ -10247,7 +10415,7 @@ * If provided, a close button is added that calls this function when clicked. * @returns {HTMLDivElement} */ - function createTitleRow (message, textButton, closeBtnFn) { + function createTitleRow(message, textButton, closeBtnFn) { // Create row container const row = document.createElement('div'); row.style.cssText = styles.titleBox; @@ -10288,7 +10456,7 @@ row.appendChild(textButton); } - return row + return row; } /** @@ -10307,7 +10475,7 @@ * Bottom row to append to the placeholder, if any. * @returns {{ contentBlock: HTMLDivElement, shadowRoot: ShadowRoot }} */ - function createContentBlock (widget, button, textButton, img, bottomRow) { + function createContentBlock(widget, button, textButton, img, bottomRow) { const contentBlock = document.createElement('div'); contentBlock.style.cssText = styles.wrapperDiv; @@ -10382,7 +10550,7 @@ shadowRoot.appendChild(feedbackRow); } - return { contentBlock, shadowRoot } + return { contentBlock, shadowRoot }; } /** @@ -10391,7 +10559,7 @@ * @param {DuckWidget} widget * @returns {{ blockingDialog: HTMLElement, shadowRoot: ShadowRoot }} */ - function createYouTubeBlockingDialog (trackingElement, widget) { + function createYouTubeBlockingDialog(trackingElement, widget) { const button = makeButton(widget.replaceSettings.buttonText, widget.getMode()); const textButton = makeTextButton(widget.replaceSettings.buttonText, widget.getMode()); @@ -10403,17 +10571,14 @@ false, '', '', - 'yt-preview-toggle' + 'yt-preview-toggle', ); - previewToggle.addEventListener( - 'click', - () => makeModal(widget.entity, () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), widget.entity) + previewToggle.addEventListener('click', () => + makeModal(widget.entity, () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), widget.entity), ); bottomRow.appendChild(previewToggle); - const { contentBlock, shadowRoot } = createContentBlock( - widget, button, textButton, null, bottomRow - ); + const { contentBlock, shadowRoot } = createContentBlock(widget, button, textButton, null, bottomRow); contentBlock.id = trackingElement.id; contentBlock.style.cssText += styles.wrapperDiv + styles.youTubeWrapperDiv; @@ -10422,8 +10587,8 @@ return { blockingDialog: contentBlock, - shadowRoot - } + shadowRoot, + }; } /** @@ -10437,7 +10602,7 @@ * @returns {{ youTubePreview: HTMLElement, shadowRoot: ShadowRoot }} * Object containing the YouTube Preview element and its shadowRoot. */ - function createYouTubePreview (originalElement, widget) { + function createYouTubePreview(originalElement, widget) { const youTubePreview = document.createElement('div'); youTubePreview.id = originalElement.id; youTubePreview.style.cssText = styles.wrapperDiv + styles.placeholderWrapperDiv; @@ -10485,10 +10650,7 @@ const textButton = makeTextButton(widget.replaceSettings.buttonText, 'darkMode'); textButton.id = titleID + 'TextButton'; - textButton.addEventListener( - 'click', - widget.clickFunction(originalElement, youTubePreview) - ); + textButton.addEventListener('click', widget.clickFunction(originalElement, youTubePreview)); topSection.appendChild(textButton); /** Play Button */ @@ -10503,10 +10665,7 @@ videoPlayImg.setAttribute('src', videoPlayIcon); playButton.appendChild(videoPlayImg); - playButton.addEventListener( - 'click', - widget.clickFunction(originalElement, youTubePreview) - ); + playButton.addEventListener('click', widget.clickFunction(originalElement, youTubePreview)); playButtonRow.appendChild(playButton); innerDiv.appendChild(playButtonRow); @@ -10522,12 +10681,9 @@ true, '', styles.youTubePreviewToggleText, - 'yt-preview-toggle' - ); - previewToggle.addEventListener( - 'click', - () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: false }) + 'yt-preview-toggle', ); + previewToggle.addEventListener('click', () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: false })); /** Preview Info Text */ const previewText = document.createElement('div'); @@ -10539,9 +10695,7 @@ // Ideally, the translation system would allow only certain element // types to be included, and would avoid the URLs for links being // included in the translations. - previewText.insertAdjacentHTML( - 'beforeend', widget.replaceSettings.placeholder.previewInfoText - ); + previewText.insertAdjacentHTML('beforeend', widget.replaceSettings.placeholder.previewInfoText); const previewTextLink = previewText.querySelector('a'); if (previewTextLink) { const newPreviewTextLink = getLearnMoreLink(widget.getMode()); @@ -10558,10 +10712,13 @@ // We use .then() instead of await here to show the placeholder right away // while the YouTube endpoint takes it time to respond. const videoURL = originalElement.src || originalElement.getAttribute('data-src'); - ctl.messaging.request('getYouTubeVideoDetails', { videoURL }) + ctl.messaging + .request('getYouTubeVideoDetails', { videoURL }) // eslint-disable-next-line promise/prefer-await-to-then .then(({ videoURL: videoURLResp, status, title, previewImage }) => { - if (!status || videoURLResp !== videoURL) { return } + if (!status || videoURLResp !== videoURL) { + return; + } if (status === 'success') { titleElement.innerText = title; titleElement.title = title; @@ -10576,7 +10733,7 @@ const feedbackRow = makeShareFeedbackRow(); shadowRoot.appendChild(feedbackRow); - return { youTubePreview, shadowRoot } + return { youTubePreview, shadowRoot }; } /** @@ -10585,15 +10742,15 @@ class ClickToLoad extends ContentFeature { /** @type {MessagingContext} */ - #messagingContext + #messagingContext; - async init (args) { + async init(args) { /** * Bail if no messaging backend - this is a debugging feature to ensure we don't * accidentally enabled this */ if (!this.messaging) { - throw new Error('Cannot operate click to load without a messaging backend') + throw new Error('Cannot operate click to load without a messaging backend'); } _messagingModuleScope = this.messaging; _addDebugFlag = this.addDebugFlag.bind(this); @@ -10617,11 +10774,9 @@ for (const entity of Object.keys(config)) { // Strip config entities that are first-party, or aren't enabled in the // extension's clickToLoad settings. - if ((websiteOwner && entity === websiteOwner) || - !settings[entity] || - settings[entity].state !== 'enabled') { + if ((websiteOwner && entity === websiteOwner) || !settings[entity] || settings[entity].state !== 'enabled') { delete config[entity]; - continue + continue; } // Populate the entities and entityData data structures. @@ -10646,12 +10801,12 @@ // Listen for window events from "surrogate" scripts. window.addEventListener('ddg-ctp', (/** @type {CustomEvent} */ event) => { - if (!('detail' in event)) return + if (!('detail' in event)) return; const entity = event.detail?.entity; if (!entities.includes(entity)) { // Unknown entity, reject - return + return; } if (event.detail?.appID) { appID = JSON.stringify(event.detail.appID).replace(/"/g, ''); @@ -10673,13 +10828,13 @@ }); // Listen to message from Platform letting CTL know that we're ready to // replace elements in the page - + this.messaging.subscribe( 'displayClickToLoadPlaceholders', // TODO: Pass `message.options.ruleAction` through, that way only // content corresponding to the entity for that ruleAction need to // be replaced with a placeholder. - () => this.replaceClickToLoadElements() + () => this.replaceClickToLoadElements(), ); // Request the current state of Click to Load from the platform. @@ -10701,11 +10856,9 @@ // dispatched too early, before the listener is ready to receive it. // To counter that, catch "ddg-ctp-surrogate-load" events dispatched // _after_ page, so the "ddg-ctp-ready" event can be dispatched again. - window.addEventListener( - 'ddg-ctp-surrogate-load', () => { - originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready')); - } - ); + window.addEventListener('ddg-ctp-surrogate-load', () => { + originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready')); + }); // Then wait for any in-progress element replacements, before letting // the surrogate scripts know to start. @@ -10720,21 +10873,21 @@ * SendMessageMessagingTransport that wraps this communication. * This can be removed once they have their own Messaging integration. */ - update (message) { + update(message) { // TODO: Once all Click to Load messages include the feature property, drop // messages that don't include the feature property too. - if (message?.feature && message?.feature !== 'clickToLoad') return + if (message?.feature && message?.feature !== 'clickToLoad') return; const messageType = message?.messageType; - if (!messageType) return + if (!messageType) return; if (!this._clickToLoadMessagingTransport) { - throw new Error('_clickToLoadMessagingTransport not ready. Cannot operate click to load without a messaging backend') + throw new Error('_clickToLoadMessagingTransport not ready. Cannot operate click to load without a messaging backend'); } // Send to Messaging layer the response or subscription message received // from the Platform. - return this._clickToLoadMessagingTransport.onResponse(message) + return this._clickToLoadMessagingTransport.onResponse(message); } /** @@ -10743,7 +10896,7 @@ * @param {boolean} state.devMode Developer or Production environment * @param {boolean} state.youtubePreviewsEnabled YouTube Click to Load - YT Previews enabled flag */ - onClickToLoadState (state) { + onClickToLoadState(state) { devMode = state.devMode; isYoutubePreviewsEnabled = state.youtubePreviewsEnabled; @@ -10759,7 +10912,7 @@ * one of the expected CSS selectors). If omitted, all matching elements * in the document will be replaced instead. */ - async replaceClickToLoadElements (targetElement) { + async replaceClickToLoadElements(targetElement) { await readyToDisplayPlaceholders; for (const entity of Object.keys(config)) { @@ -10775,16 +10928,18 @@ trackingElements = Array.from(document.querySelectorAll(selector)); } - await Promise.all(trackingElements.map(trackingElement => { - if (knownTrackingElements.has(trackingElement)) { - return Promise.resolve() - } + await Promise.all( + trackingElements.map((trackingElement) => { + if (knownTrackingElements.has(trackingElement)) { + return Promise.resolve(); + } - knownTrackingElements.add(trackingElement); + knownTrackingElements.add(trackingElement); - const widget = new DuckWidget(widgetData, trackingElement, entity, this.platform); - return createPlaceholderElementAndReplace(widget, trackingElement) - })); + const widget = new DuckWidget(widgetData, trackingElement, entity, this.platform); + return createPlaceholderElementAndReplace(widget, trackingElement); + }), + ); } } } @@ -10792,31 +10947,31 @@ /** * @returns {MessagingContext} */ - get messagingContext () { - if (this.#messagingContext) return this.#messagingContext + get messagingContext() { + if (this.#messagingContext) return this.#messagingContext; this.#messagingContext = this._createMessagingContext(); - return this.#messagingContext + return this.#messagingContext; } // Messaging layer between Click to Load and the Platform - get messaging () { - if (this._messaging) return this._messaging + get messaging() { + if (this._messaging) return this._messaging; if (this.platform.name === 'android' || this.platform.name === 'extension') { this._clickToLoadMessagingTransport = new SendMessageMessagingTransport(); const config = new TestTransportConfig(this._clickToLoadMessagingTransport); this._messaging = new Messaging(this.messagingContext, config); - return this._messaging + return this._messaging; } else if (this.platform.name === 'ios' || this.platform.name === 'macos') { const config = new WebkitMessagingConfig({ secret: '', hasModernWebkitAPI: true, - webkitMessageHandlerNames: ['contentScopeScriptsIsolated'] + webkitMessageHandlerNames: ['contentScopeScriptsIsolated'], }); this._messaging = new Messaging(this.messagingContext, config); - return this._messaging + return this._messaging; } else { - throw new Error('Messaging not supported yet on platform: ' + this.name) + throw new Error('Messaging not supported yet on platform: ' + this.name); } } } @@ -10824,21 +10979,21 @@ /** * @returns array of performance metrics */ - function getJsPerformanceMetrics () { + function getJsPerformanceMetrics() { const paintResources = performance.getEntriesByType('paint'); const firstPaint = paintResources.find((entry) => entry.name === 'first-contentful-paint'); - return firstPaint ? [firstPaint.startTime] : [] + return firstPaint ? [firstPaint.startTime] : []; } class BreakageReporting extends ContentFeature { - init () { + init() { this.messaging.subscribe('getBreakageReportValues', () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; this.messaging.notify('breakageReportResult', { jsPerformance, - referrer + referrer, }); }); } @@ -10871,7 +11026,7 @@ * @param {import('./overlays.js').Environment} environment * @internal */ - constructor (messaging, environment) { + constructor(messaging, environment) { /** * @internal */ @@ -10882,17 +11037,17 @@ /** * @returns {Promise} */ - initialSetup () { + initialSetup() { if (this.environment.isIntegrationMode()) { return Promise.resolve({ userValues: { overlayInteracted: false, - privatePlayerMode: { alwaysAsk: {} } + privatePlayerMode: { alwaysAsk: {} }, }, - ui: {} - }) + ui: {}, + }); } - return this.messaging.request(MSG_NAME_INITIAL_SETUP) + return this.messaging.request(MSG_NAME_INITIAL_SETUP); } /** @@ -10900,24 +11055,24 @@ * @param {import("../duck-player.js").UserValues} userValues * @returns {Promise} */ - setUserValues (userValues) { - return this.messaging.request(MSG_NAME_SET_VALUES, userValues) + setUserValues(userValues) { + return this.messaging.request(MSG_NAME_SET_VALUES, userValues); } /** * @returns {Promise} */ - getUserValues () { - return this.messaging.request(MSG_NAME_READ_VALUES, {}) + getUserValues() { + return this.messaging.request(MSG_NAME_READ_VALUES, {}); } /** * @param {Pixel} pixel */ - sendPixel (pixel) { + sendPixel(pixel) { this.messaging.notify(MSG_NAME_PIXEL, { pixelName: pixel.name(), - params: pixel.params() + params: pixel.params(), }); } @@ -10926,43 +11081,45 @@ * See {@link OpenInDuckPlayerMsg} for params * @param {OpenInDuckPlayerMsg} params */ - openDuckPlayer (params) { - return this.messaging.notify(MSG_NAME_OPEN_PLAYER, params) + openDuckPlayer(params) { + return this.messaging.notify(MSG_NAME_OPEN_PLAYER, params); } /** * This is sent when the user wants to open Duck Player. */ - openInfo () { - return this.messaging.notify(MSG_NAME_OPEN_INFO) + openInfo() { + return this.messaging.notify(MSG_NAME_OPEN_INFO); } /** * Get notification when preferences/state changed * @param {(userValues: import("../duck-player.js").UserValues) => void} cb */ - onUserValuesChanged (cb) { - return this.messaging.subscribe('onUserValuesChanged', cb) + onUserValuesChanged(cb) { + return this.messaging.subscribe('onUserValuesChanged', cb); } /** * Get notification when ui settings changed * @param {(userValues: import("../duck-player.js").UISettings) => void} cb */ - onUIValuesChanged (cb) { - return this.messaging.subscribe('onUIValuesChanged', cb) + onUIValuesChanged(cb) { + return this.messaging.subscribe('onUIValuesChanged', cb); } /** * This allows our SERP to interact with Duck Player settings. */ - serpProxy () { - function respond (kind, data) { - window.dispatchEvent(new CustomEvent(MSG_NAME_PROXY_RESPONSE, { - detail: { kind, data }, - composed: true, - bubbles: true - })); + serpProxy() { + function respond(kind, data) { + window.dispatchEvent( + new CustomEvent(MSG_NAME_PROXY_RESPONSE, { + detail: { kind, data }, + composed: true, + bubbles: true, + }), + ); } // listen for setting and forward to the SERP window @@ -10976,16 +11133,16 @@ assertCustomEvent(evt); if (evt.detail.kind === MSG_NAME_SET_VALUES) { return this.setUserValues(evt.detail.data) - .then(updated => respond(MSG_NAME_PUSH_DATA, updated)) - .catch(console.error) + .then((updated) => respond(MSG_NAME_PUSH_DATA, updated)) + .catch(console.error); } if (evt.detail.kind === MSG_NAME_READ_VALUES_SERP) { return this.getUserValues() - .then(updated => respond(MSG_NAME_PUSH_DATA, updated)) - .catch(console.error) + .then((updated) => respond(MSG_NAME_PUSH_DATA, updated)) + .catch(console.error); } if (evt.detail.kind === MSG_NAME_OPEN_INFO) { - return this.openInfo() + return this.openInfo(); } console.warn('unhandled event', evt); } catch (e) { @@ -10999,9 +11156,9 @@ * @param {any} event * @returns {asserts event is CustomEvent<{kind: string, data: any}>} */ - function assertCustomEvent (event) { - if (!('detail' in event)) throw new Error('none-custom event') - if (typeof event.detail.kind !== 'string') throw new Error('custom event requires detail.kind to be a string') + function assertCustomEvent(event) { + if (!('detail' in event)) throw new Error('none-custom event'); + if (typeof event.detail.kind !== 'string') throw new Error('custom event requires detail.kind to be a string'); } class Pixel { @@ -11012,23 +11169,26 @@ * | {name: "play.use.thumbnail"} * | {name: "play.do_not_use", remember: "0" | "1"}} input */ - constructor (input) { + constructor(input) { this.input = input; } - name () { - return this.input.name + name() { + return this.input.name; } - params () { + params() { switch (this.input.name) { - case 'overlay': return {} - case 'play.use.thumbnail': return {} - case 'play.use': - case 'play.do_not_use': { - return { remember: this.input.remember } - } - default: throw new Error('unreachable') + case 'overlay': + return {}; + case 'play.use.thumbnail': + return {}; + case 'play.use': + case 'play.do_not_use': { + return { remember: this.input.remember }; + } + default: + throw new Error('unreachable'); } } } @@ -11038,7 +11198,7 @@ * @param {object} params * @param {string} params.href */ - constructor (params) { + constructor(params) { this.href = params.href; } } @@ -11058,7 +11218,7 @@ * @param {string} targetSelector * @param {string} imageUrl */ - function appendImageAsBackground (parent, targetSelector, imageUrl) { + function appendImageAsBackground(parent, targetSelector, imageUrl) { /** * Make a HEAD request to see what the status of this image is, without @@ -11067,23 +11227,25 @@ * This is needed because YouTube returns a 404 + valid image file when there's no * thumbnail and you can't tell the difference through the 'onload' event alone */ - fetch(imageUrl, { method: 'HEAD' }).then(x => { - const status = String(x.status); - if (status.startsWith('2')) { - { - append(); + fetch(imageUrl, { method: 'HEAD' }) + .then((x) => { + const status = String(x.status); + if (status.startsWith('2')) { + { + append(); + } + } else { + markError(); } - } else { - markError(); - } - }).catch(() => { - console.error('e from fetch'); - }); + }) + .catch(() => { + console.error('e from fetch'); + }); /** * If loading fails, mark the parent with data-attributes */ - function markError () { + function markError() { parent.dataset.thumbLoaded = String(false); parent.dataset.error = String(true); } @@ -11091,9 +11253,11 @@ /** * If loading succeeds, try to append the image */ - function append () { + function append() { const targetElement = parent.querySelector(targetSelector); - if (!(targetElement instanceof HTMLElement)) return console.warn('could not find child with selector', targetSelector, 'from', parent) + if (!(targetElement instanceof HTMLElement)) { + return console.warn('could not find child with selector', targetSelector, 'from', parent); + } parent.dataset.thumbLoaded = String(true); parent.dataset.thumbSrc = imageUrl; const img = new Image(); @@ -11105,7 +11269,7 @@ img.onerror = function () { markError(); const targetElement = parent.querySelector(targetSelector); - if (!(targetElement instanceof HTMLElement)) return + if (!(targetElement instanceof HTMLElement)) return; targetElement.style.backgroundImage = ''; }; } @@ -11116,19 +11280,19 @@ * @param {object} params * @param {boolean} [params.debug] */ - constructor ({ debug = false } = { }) { + constructor({ debug = false } = {}) { this.debug = debug; } /** @type {{fn: () => void, name: string}[]} */ - _cleanups = [] + _cleanups = []; /** * Wrap a side-effecting operation for easier debugging * and teardown/release of resources * @param {string} name * @param {() => () => void} fn */ - add (name, fn) { + add(name, fn) { try { if (this.debug) { console.log('☢️', name); @@ -11145,7 +11309,7 @@ /** * Remove elements, event listeners etc */ - destroy () { + destroy() { for (const cleanup of this._cleanups) { if (typeof cleanup.fn === 'function') { try { @@ -11157,7 +11321,7 @@ console.error(`cleanup ${cleanup.name} threw`, e); } } else { - throw new Error('invalid cleanup') + throw new Error('invalid cleanup'); } } this._cleanups = []; @@ -11184,18 +11348,18 @@ * @param {string} id - the YouTube video ID * @param {string|null|undefined} time - an optional time */ - constructor (id, time) { + constructor(id, time) { this.id = id; this.time = time; } - static validVideoId = /^[a-zA-Z0-9-_]+$/ - static validTimestamp = /^[0-9hms]+$/ + static validVideoId = /^[a-zA-Z0-9-_]+$/; + static validTimestamp = /^[0-9hms]+$/; /** * @returns {string} */ - toPrivatePlayerUrl () { + toPrivatePlayerUrl() { // no try/catch because we already validated the ID // in Microsoft WebView2 v118+ changing from special protocol (https) to non-special one (duck) is forbidden // so we need to construct duck player this way @@ -11204,7 +11368,7 @@ if (this.time) { duckUrl.searchParams.set('t', this.time); } - return duckUrl.href + return duckUrl.href; } /** @@ -11213,17 +11377,17 @@ * @param {string} href * @returns {VideoParams|null} */ - static forWatchPage (href) { + static forWatchPage(href) { let url; try { url = new URL(href); } catch (e) { - return null + return null; } if (!url.pathname.startsWith('/watch')) { - return null + return null; } - return VideoParams.fromHref(url.href) + return VideoParams.fromHref(url.href); } /** @@ -11232,14 +11396,14 @@ * @param pathname * @returns {VideoParams|null} */ - static fromPathname (pathname) { + static fromPathname(pathname) { let url; try { url = new URL(pathname, window.location.origin); } catch (e) { - return null + return null; } - return VideoParams.fromHref(url.href) + return VideoParams.fromHref(url.href); } /** @@ -11249,12 +11413,12 @@ * @param href * @returns {VideoParams|null} */ - static fromHref (href) { + static fromHref(href) { let url; try { url = new URL(href); } catch (e) { - return null + return null; } let id = null; @@ -11267,7 +11431,7 @@ // valid: '/watch?v=321&list=123&index=1234' // invalid: '/watch?v=321&list=123' <- index absent if (url.searchParams.has('list') && !url.searchParams.has('index')) { - return null + return null; } let time = null; @@ -11277,7 +11441,7 @@ id = vParam; } else { // if the video ID is invalid, we cannot produce an instance of VideoParams - return null + return null; } // ensure timestamp is good, if set @@ -11285,7 +11449,7 @@ time = tParam; } - return new VideoParams(id, time) + return new VideoParams(id, time); } } @@ -11296,17 +11460,17 @@ * if the DOM is already loaded. */ class DomState { - loaded = false - loadedCallbacks = [] - constructor () { + loaded = false; + loadedCallbacks = []; + constructor() { window.addEventListener('DOMContentLoaded', () => { this.loaded = true; - this.loadedCallbacks.forEach(cb => cb()); + this.loadedCallbacks.forEach((cb) => cb()); }); } - onLoaded (loadedCallback) { - if (this.loaded) return loadedCallback() + onLoaded(loadedCallback) { + if (this.loaded) return loadedCallback(); this.loadedCallbacks.push(loadedCallback); } } @@ -11320,56 +11484,56 @@ */ const text = { playText: { - title: 'Duck Player' + title: 'Duck Player', }, videoOverlayTitle: { - title: 'Tired of targeted YouTube ads and recommendations?' + title: 'Tired of targeted YouTube ads and recommendations?', }, videoOverlayTitle2: { - title: 'Turn on Duck Player to watch without targeted ads' + title: 'Turn on Duck Player to watch without targeted ads', }, videoOverlayTitle3: { - title: 'Drowning in ads on YouTube? {newline} Turn on Duck Player.' + title: 'Drowning in ads on YouTube? {newline} Turn on Duck Player.', }, videoOverlaySubtitle: { - title: 'provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations.' + title: 'provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations.', }, videoOverlaySubtitle2: { - title: 'What you watch in DuckDuckGo won’t influence your recommendations on YouTube.' + title: 'What you watch in DuckDuckGo won’t influence your recommendations on YouTube.', }, videoButtonOpen: { - title: 'Watch in Duck Player' + title: 'Watch in Duck Player', }, videoButtonOpen2: { - title: 'Turn On Duck Player' + title: 'Turn On Duck Player', }, videoButtonOptOut: { - title: 'Watch Here' + title: 'Watch Here', }, videoButtonOptOut2: { - title: 'No Thanks' + title: 'No Thanks', }, rememberLabel: { - title: 'Remember my choice' - } + title: 'Remember my choice', + }, }; const i18n = { /** * @param {keyof text} name */ - t (name) { + t(name) { // eslint-disable-next-line no-prototype-builtins if (!text.hasOwnProperty(name)) { console.error(`missing key ${name}`); - return 'missing' + return 'missing'; } const match = text[name]; if (!match.title) { - return 'missing' + return 'missing'; } - return match.title - } + return match.title; + }, }; /** @@ -11394,8 +11558,8 @@ subtitle: i18n.t('videoOverlaySubtitle2'), buttonOptOut: i18n.t('videoButtonOptOut2'), buttonOpen: i18n.t('videoButtonOpen2'), - rememberLabel: i18n.t('rememberLabel') - } + rememberLabel: i18n.t('rememberLabel'), + }, }; /** @@ -11408,29 +11572,29 @@ subtitle: lookup.videoOverlaySubtitle2, buttonOptOut: lookup.videoButtonOptOut2, buttonOpen: lookup.videoButtonOpen2, - rememberLabel: lookup.rememberLabel - } + rememberLabel: lookup.rememberLabel, + }; }; class IconOverlay { - sideEffects = new SideEffects() - policy = createPolicy() + sideEffects = new SideEffects(); + policy = createPolicy(); /** @type {HTMLElement | null} */ - element = null + element = null; /** * Special class used for the overlay hover. For hovering, we use a * single element and move it around to the hovered video element. */ - HOVER_CLASS = 'ddg-overlay-hover' - OVERLAY_CLASS = 'ddg-overlay' + HOVER_CLASS = 'ddg-overlay-hover'; + OVERLAY_CLASS = 'ddg-overlay'; - CSS_OVERLAY_MARGIN_TOP = 5 - CSS_OVERLAY_HEIGHT = 32 + CSS_OVERLAY_MARGIN_TOP = 5; + CSS_OVERLAY_HEIGHT = 32; /** @type {HTMLElement | null} */ - currentVideoElement = null - hoverOverlayVisible = false + currentVideoElement = null; + hoverOverlayVisible = false; /** * Creates an Icon Overlay. @@ -11439,55 +11603,49 @@ * @param {string} [extraClass] - whether to add any extra classes, such as hover * @returns {HTMLElement} */ - create (size, href, extraClass) { + create(size, href, extraClass) { const overlayElement = document.createElement('div'); overlayElement.setAttribute('class', 'ddg-overlay' + (extraClass ? ' ' + extraClass : '')); overlayElement.setAttribute('data-size', size); const svgIcon = trustedUnsafe(dax); - const safeString = html` - -
- ${svgIcon} -
-
-
- ${i18n.t('playText')} -
-
-
`.toString(); + const safeString = html` +
${svgIcon}
+
+
${i18n.t('playText')}
+
+
`.toString(); overlayElement.innerHTML = this.policy.createHTML(safeString); overlayElement.querySelector('a.ddg-play-privately')?.setAttribute('href', href); - return overlayElement + return overlayElement; } /** * Util to return the hover overlay * @returns {HTMLElement | null} */ - getHoverOverlay () { - return document.querySelector('.' + this.HOVER_CLASS) + getHoverOverlay() { + return document.querySelector('.' + this.HOVER_CLASS); } /** * Moves the hover overlay to a specified videoElement * @param {HTMLElement} videoElement - which element to move it to */ - moveHoverOverlayToVideoElement (videoElement) { + moveHoverOverlayToVideoElement(videoElement) { const overlay = this.getHoverOverlay(); if (overlay === null || this.videoScrolledOutOfViewInPlaylist(videoElement)) { - return + return; } const videoElementOffset = this.getElementOffset(videoElement); - overlay.setAttribute('style', '' + - 'top: ' + videoElementOffset.top + 'px;' + - 'left: ' + videoElementOffset.left + 'px;' + - 'display:block;' + overlay.setAttribute( + 'style', + '' + 'top: ' + videoElementOffset.top + 'px;' + 'left: ' + videoElementOffset.left + 'px;' + 'display:block;', ); overlay.setAttribute('data-size', 'fixed ' + this.getThumbnailSize(videoElement)); @@ -11511,22 +11669,22 @@ * @param {HTMLElement} videoElement * @returns {boolean} */ - videoScrolledOutOfViewInPlaylist (videoElement) { + videoScrolledOutOfViewInPlaylist(videoElement) { const inPlaylist = videoElement.closest('#items.playlist-items'); if (inPlaylist) { const video = videoElement.getBoundingClientRect(); const playlist = inPlaylist.getBoundingClientRect(); - const videoOutsideTop = (video.top + this.CSS_OVERLAY_MARGIN_TOP) < playlist.top; - const videoOutsideBottom = ((video.top + this.CSS_OVERLAY_HEIGHT + this.CSS_OVERLAY_MARGIN_TOP) > playlist.bottom); + const videoOutsideTop = video.top + this.CSS_OVERLAY_MARGIN_TOP < playlist.top; + const videoOutsideBottom = video.top + this.CSS_OVERLAY_HEIGHT + this.CSS_OVERLAY_MARGIN_TOP > playlist.bottom; if (videoOutsideTop || videoOutsideBottom) { - return true + return true; } } - return false + return false; } /** @@ -11534,19 +11692,19 @@ * @param {HTMLElement} el * @returns {Object} */ - getElementOffset (el) { + getElementOffset(el) { const box = el.getBoundingClientRect(); const docElem = document.documentElement; return { top: box.top + window.pageYOffset - docElem.clientTop, - left: box.left + window.pageXOffset - docElem.clientLeft - } + left: box.left + window.pageXOffset - docElem.clientLeft, + }; } /** * Hides the hover overlay element, but only if mouse pointer is outside of the hover overlay element */ - hideHoverOverlay (event, force) { + hideHoverOverlay(event, force) { const overlay = this.getHoverOverlay(); const toElement = event.toElement; @@ -11555,7 +11713,7 @@ // Prevent hiding overlay if mouseleave is triggered by user is actually hovering it and that // triggered the mouseleave event if (toElement === overlay || overlay.contains(toElement) || force) { - return + return; } this.hideOverlay(overlay); @@ -11567,7 +11725,7 @@ * Util for hiding an overlay * @param {HTMLElement} overlay */ - hideOverlay (overlay) { + hideOverlay(overlay) { overlay.setAttribute('style', 'display:none;'); } @@ -11578,7 +11736,7 @@ * inside a video thumbnail when hovering the overlay. Nice. * @param {(href: string) => void} onClick */ - appendHoverOverlay (onClick) { + appendHoverOverlay(onClick) { this.sideEffects.add('Adding the re-usable overlay to the page ', () => { // add the CSS to the head const cleanUpCSS = this.loadCSS(); @@ -11592,11 +11750,11 @@ return () => { element.remove(); cleanUpCSS(); - } + }; }); } - loadCSS () { + loadCSS() { // add the CSS to the head const id = '__ddg__icon'; const style = document.head.querySelector(`#${id}`); @@ -11611,7 +11769,7 @@ if (style) { document.head.removeChild(style); } - } + }; } /** @@ -11619,7 +11777,7 @@ * @param {string} href * @param {(href: string) => void} onClick */ - appendSmallVideoOverlay (container, href, onClick) { + appendSmallVideoOverlay(container, href, onClick) { this.sideEffects.add('Adding a small overlay for the video player', () => { // add the CSS to the head const cleanUpCSS = this.loadCSS(); @@ -11634,30 +11792,31 @@ return () => { element?.remove(); cleanUpCSS(); - } + }; }); } - getThumbnailSize (videoElement) { + getThumbnailSize(videoElement) { const imagesByArea = {}; - Array.from(videoElement.querySelectorAll('img')).forEach(image => { - imagesByArea[(image.offsetWidth * image.offsetHeight)] = image; + Array.from(videoElement.querySelectorAll('img')).forEach((image) => { + imagesByArea[image.offsetWidth * image.offsetHeight] = image; }); const largestImage = Math.max.apply(this, Object.keys(imagesByArea).map(Number)); const getSizeType = (width, height) => { - if (width < (123 + 10)) { // match CSS: width of expanded overlay + twice the left margin. - return 'small' + if (width < 123 + 10) { + // match CSS: width of expanded overlay + twice the left margin. + return 'small'; } else if (width < 300 && height < 175) { - return 'medium' + return 'medium'; } else { - return 'large' + return 'large'; } }; - return getSizeType(imagesByArea[largestImage].offsetWidth, imagesByArea[largestImage].offsetHeight) + return getSizeType(imagesByArea[largestImage].offsetWidth, imagesByArea[largestImage].offsetHeight); } /** @@ -11667,11 +11826,11 @@ * @param {HTMLElement} element - the wrapping div * @param {(href: string) => void} callback - the function to execute following a click */ - addClickHandler (element, callback) { + addClickHandler(element, callback) { element.addEventListener('click', (event) => { event.preventDefault(); event.stopImmediatePropagation(); - const link = /** @type {HTMLElement} */(event.target).closest('a'); + const link = /** @type {HTMLElement} */ (event.target).closest('a'); const href = link?.getAttribute('href'); if (href) { callback(href); @@ -11679,7 +11838,7 @@ }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } @@ -11750,11 +11909,11 @@ * This features covers the implementation */ class Thumbnails { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** * @param {ThumbnailParams} params */ - constructor (params) { + constructor(params) { this.settings = params.settings; this.messages = params.messages; this.environment = params.environment; @@ -11763,7 +11922,7 @@ /** * Perform side effects */ - init () { + init() { this.sideEffects.add('showing overlays on hover', () => { const { selectors } = this.settings; const parentNode = document.documentElement || document.body; @@ -11814,33 +11973,33 @@ // detect hovers and decide to show hover icon, or not const mouseOverHandler = (e) => { - if (clicked) return + if (clicked) return; const hoverElement = findElementFromEvent(selectors.thumbLink, selectors.hoverExcluded, e); const validLink = isValidLink(hoverElement, selectors.excludedRegions); // if it's not an element we care about, bail early and remove the overlay if (!hoverElement || !validLink) { - return removeOverlay() + return removeOverlay(); } // ensure it doesn't contain sub-links if (hoverElement.querySelector('a[href]')) { - return removeOverlay() + return removeOverlay(); } // only add Dax when this link also contained an img if (!hoverElement.querySelector('img')) { - return removeOverlay() + return removeOverlay(); } // if the hover target is the match, or contains the match, all good if (e.target === hoverElement || hoverElement?.contains(e.target)) { - return appendOverlay(hoverElement) + return appendOverlay(hoverElement); } // finally, check the 'allowedEventTargets' to see if the hover occurred in an element // that we know to be a thumbnail overlay, like a preview - const matched = selectors.allowedEventTargets.find(css => e.target.matches(css)); + const matched = selectors.allowedEventTargets.find((css) => e.target.matches(css)); if (matched) { appendOverlay(hoverElement); } @@ -11852,21 +12011,21 @@ parentNode.removeEventListener('mouseover', mouseOverHandler, true); parentNode.removeEventListener('click', clickHandler, true); icon.destroy(); - } + }; }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } class ClickInterception { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** * @param {ThumbnailParams} params */ - constructor (params) { + constructor(params) { this.settings = params.settings; this.messages = params.messages; this.environment = params.environment; @@ -11875,7 +12034,7 @@ /** * Perform side effects */ - init () { + init() { this.sideEffects.add('intercepting clicks', () => { const { selectors } = this.settings; const parentNode = document.documentElement || document.body; @@ -11892,17 +12051,17 @@ // if there's no match, return early if (!validLink) { - return + return; } // if the hover target is the match, or contains the match, all good if (e.target === elementInStack || elementInStack?.contains(e.target)) { - return block(validLink) + return block(validLink); } // finally, check the 'allowedEventTargets' to see if the hover occurred in an element // that we know to be a thumbnail overlay, like a preview - const matched = selectors.allowedEventTargets.find(css => e.target.matches(css)); + const matched = selectors.allowedEventTargets.find((css) => e.target.matches(css)); if (matched) { block(validLink); } @@ -11912,11 +12071,11 @@ return () => { parentNode.removeEventListener('click', clickHandler, true); - } + }; }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } @@ -11927,7 +12086,7 @@ * @param {MouseEvent} e * @return {HTMLElement|null} */ - function findElementFromEvent (selector, excludedSelectors, e) { + function findElementFromEvent(selector, excludedSelectors, e) { /** @type {HTMLElement | null} */ let matched = null; @@ -11935,8 +12094,8 @@ for (const element of document.elementsFromPoint(e.clientX, e.clientY)) { // bail early if this item was excluded anywhere in the element stack - if (excludedSelectors.some(ex => element.matches(ex))) { - return null + if (excludedSelectors.some((ex) => element.matches(ex))) { + return null; } // we cannot return this immediately, because another element in the stack @@ -11944,11 +12103,11 @@ if (element.matches(selector)) { // in lots of cases we can just return the element as soon as it's found, to prevent // checking the entire stack - matched = /** @type {HTMLElement} */(element); - if (fastPath) return matched + matched = /** @type {HTMLElement} */ (element); + if (fastPath) return matched; } } - return matched + return matched; } /** @@ -11956,36 +12115,36 @@ * @param {string[]} excludedRegions * @return {string | null | undefined} */ - function isValidLink (element, excludedRegions) { - if (!element) return null + function isValidLink(element, excludedRegions) { + if (!element) return null; /** * Does this element exist inside an excluded region? */ - const existsInExcludedParent = excludedRegions.some(selector => { + const existsInExcludedParent = excludedRegions.some((selector) => { for (const parent of document.querySelectorAll(selector)) { - if (parent.contains(element)) return true + if (parent.contains(element)) return true; } - return false + return false; }); /** * Does this element exist inside an excluded region? * If so, bail */ - if (existsInExcludedParent) return null + if (existsInExcludedParent) return null; /** * We shouldn't be able to get here, but this keeps Typescript happy * and is a good check regardless */ - if (!('href' in element)) return null + if (!('href' in element)) return null; /** * If we get here, we're trying to convert the `element.href` * into a valid Duck Player URL */ - return VideoParams.fromHref(element.href)?.toPrivatePlayerUrl() + return VideoParams.fromHref(element.href)?.toPrivatePlayerUrl(); } var css = "/* -- VIDEO PLAYER OVERLAY */\n:host {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n z-index: 10000;\n}\n:host * {\n font-family: system, -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n}\n.ddg-video-player-overlay {\n font-size: 13px;\n font-weight: 400;\n line-height: 16px;\n text-align: center;\n\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n z-index: 10000;\n}\n\n.ddg-eyeball svg {\n width: 60px;\n height: 60px;\n}\n\n.ddg-vpo-bg {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n text-align: center;\n background: black;\n}\n\n.ddg-vpo-bg:after {\n content: \" \";\n position: absolute;\n display: block;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0,0,0,1); /* this gets overriden if the background image can be found */\n color: white;\n text-align: center;\n}\n\n.ddg-video-player-overlay[data-thumb-loaded=\"true\"] .ddg-vpo-bg:after {\n background: rgba(0,0,0,0.75);\n}\n\n.ddg-vpo-content {\n position: relative;\n top: 50%;\n transform: translate(-50%, -50%);\n left: 50%;\n max-width: 90%;\n}\n\n.ddg-vpo-eyeball {\n margin-bottom: 18px;\n}\n\n.ddg-vpo-title {\n font-size: 22px;\n font-weight: 400;\n line-height: 26px;\n margin-top: 25px;\n}\n\n.ddg-vpo-text {\n margin-top: 16px;\n width: 496px;\n margin-left: auto;\n margin-right: auto;\n}\n\n.ddg-vpo-text b {\n font-weight: 600;\n}\n\n.ddg-vpo-buttons {\n margin-top: 25px;\n}\n.ddg-vpo-buttons > * {\n display: inline-block;\n margin: 0;\n padding: 0;\n}\n\n.ddg-vpo-button {\n color: white;\n padding: 9px 16px;\n font-size: 13px;\n border-radius: 8px;\n font-weight: 600;\n display: inline-block;\n text-decoration: none;\n}\n\n.ddg-vpo-button + .ddg-vpo-button {\n margin-left: 10px;\n}\n\n.ddg-vpo-cancel {\n background: #585b58;\n border: 0.5px solid rgba(40, 145, 255, 0.05);\n box-shadow: 0px 0px 0px 0.5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 1px 1px rgba(0, 0, 0, 0.2), inset 0px 0.5px 0px rgba(255, 255, 255, 0.2), inset 0px 1px 0px rgba(255, 255, 255, 0.05);\n}\n\n.ddg-vpo-open {\n background: #3969EF;\n border: 0.5px solid rgba(40, 145, 255, 0.05);\n box-shadow: 0px 0px 0px 0.5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 1px 1px rgba(0, 0, 0, 0.2), inset 0px 0.5px 0px rgba(255, 255, 255, 0.2), inset 0px 1px 0px rgba(255, 255, 255, 0.05);\n}\n\n.ddg-vpo-open:hover {\n background: #1d51e2;\n}\n.ddg-vpo-cancel:hover {\n cursor: pointer;\n background: #2f2f2f;\n}\n\n.ddg-vpo-remember {\n}\n.ddg-vpo-remember label {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-top: 25px;\n cursor: pointer;\n}\n.ddg-vpo-remember input {\n margin-right: 6px;\n}\n"; @@ -11995,9 +12154,9 @@ * over the YouTube player */ class DDGVideoOverlay extends HTMLElement { - policy = createPolicy() + policy = createPolicy(); - static CUSTOM_TAG_NAME = 'ddg-video-overlay' + static CUSTOM_TAG_NAME = 'ddg-video-overlay'; /** * @param {object} options * @param {import("../overlays.js").Environment} options.environment @@ -12005,9 +12164,9 @@ * @param {import("../../duck-player.js").UISettings} options.ui * @param {VideoOverlay} options.manager */ - constructor ({ environment, params, ui, manager }) { + constructor({ environment, params, ui, manager }) { super(); - if (!(manager instanceof VideoOverlay)) throw new Error('invalid arguments') + if (!(manager instanceof VideoOverlay)) throw new Error('invalid arguments'); this.environment = environment; this.ui = ui; this.params = params; @@ -12042,7 +12201,7 @@ /** * @returns {HTMLDivElement} */ - createOverlay () { + createOverlay() { const overlayCopy = overlayCopyVariants.default; const overlayElement = document.createElement('div'); overlayElement.classList.add('ddg-video-player-overlay'); @@ -12052,20 +12211,16 @@
${svgIcon}
${overlayCopy.title}
-
- ${overlayCopy.subtitle} -
+
${overlayCopy.subtitle}
${overlayCopy.buttonOpen}
- +
- `.toString(); + `.toString(); overlayElement.innerHTML = this.policy.createHTML(safeString); @@ -12086,14 +12241,14 @@ */ this.setupButtonsInsideOverlay(overlayElement, this.params); - return overlayElement + return overlayElement; } /** * @param {HTMLElement} overlayElement * @param {string} videoId */ - appendThumbnail (overlayElement, videoId) { + appendThumbnail(overlayElement, videoId) { const imageUrl = this.environment.getLargeThumbnailSrc(videoId); appendImageAsBackground(overlayElement, '.ddg-vpo-bg', imageUrl); } @@ -12102,15 +12257,15 @@ * @param {HTMLElement} containerElement * @param {import("../util").VideoParams} params */ - setupButtonsInsideOverlay (containerElement, params) { + setupButtonsInsideOverlay(containerElement, params) { const cancelElement = containerElement.querySelector('.ddg-vpo-cancel'); const watchInPlayer = containerElement.querySelector('.ddg-vpo-open'); - if (!cancelElement) return console.warn('Could not access .ddg-vpo-cancel') - if (!watchInPlayer) return console.warn('Could not access .ddg-vpo-open') + if (!cancelElement) return console.warn('Could not access .ddg-vpo-cancel'); + if (!watchInPlayer) return console.warn('Could not access .ddg-vpo-open'); const optOutHandler = (e) => { if (e.isTrusted) { const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input') + if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); this.manager.userOptOut(remember.checked, params); } }; @@ -12118,7 +12273,7 @@ if (e.isTrusted) { e.preventDefault(); const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input') + if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); this.manager.userOptIn(remember.checked, params); } }; @@ -12141,22 +12296,22 @@ * over the YouTube player */ class DDGVideoOverlayMobile extends HTMLElement { - static CUSTOM_TAG_NAME = 'ddg-video-overlay-mobile' - static OPEN_INFO = 'open-info' - static OPT_IN = 'opt-in' - static OPT_OUT = 'opt-out' + static CUSTOM_TAG_NAME = 'ddg-video-overlay-mobile'; + static OPEN_INFO = 'open-info'; + static OPT_IN = 'opt-in'; + static OPT_OUT = 'opt-out'; - policy = createPolicy() + policy = createPolicy(); /** @type {boolean} */ - testMode = false + testMode = false; /** @type {Text | null} */ - text = null + text = null; - connectedCallback () { + connectedCallback() { this.createMarkupAndStyles(); } - createMarkupAndStyles () { + createMarkupAndStyles() { const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); const style = document.createElement('style'); style.innerText = mobilecss; @@ -12170,10 +12325,10 @@ /** * @returns {string} */ - mobileHtml () { + mobileHtml() { if (!this.text) { console.warn('missing `text`. Please assign before rendering'); - return '' + return ''; } const svgIcon = trustedUnsafe(dax); const infoIcon = trustedUnsafe(info); @@ -12184,24 +12339,18 @@
${this.text.title}
- -
-
- ${this.text.subtitle} +
+
${this.text.subtitle}
${this.text.buttonOpen}
- - ${this.text.rememberLabel} - + ${this.text.rememberLabel} - + @@ -12210,24 +12359,22 @@
- `.toString() + `.toString(); } /** * @param {HTMLElement} containerElement */ - setupEventHandlers (containerElement) { + setupEventHandlers(containerElement) { const switchElem = containerElement.querySelector('[role=switch]'); const infoButton = containerElement.querySelector('.button--info'); const remember = containerElement.querySelector('input[name="ddg-remember"]'); const cancelElement = containerElement.querySelector('.ddg-vpo-cancel'); const watchInPlayer = containerElement.querySelector('.ddg-vpo-open'); - if (!infoButton || - !cancelElement || - !watchInPlayer || - !switchElem || - !(remember instanceof HTMLInputElement)) return console.warn('missing elements') + if (!infoButton || !cancelElement || !watchInPlayer || !switchElem || !(remember instanceof HTMLInputElement)) { + return console.warn('missing elements'); + } infoButton.addEventListener('click', () => { this.dispatchEvent(new Event(DDGVideoOverlayMobile.OPEN_INFO)); @@ -12245,14 +12392,14 @@ }); cancelElement.addEventListener('click', (e) => { - if (!e.isTrusted) return + if (!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_OUT, { detail: { remember: remember.checked } })); }); watchInPlayer.addEventListener('click', (e) => { - if (!e.isTrusted) return + if (!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_IN, { detail: { remember: remember.checked } })); @@ -12293,13 +12440,13 @@ * + conduct any communications */ class VideoOverlay { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** @type {string | null} */ - lastVideoId = null + lastVideoId = null; /** @type {boolean} */ - didAllowFirstVideo = false + didAllowFirstVideo = false; /** * @param {object} options @@ -12309,7 +12456,7 @@ * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} options.messages * @param {import("../duck-player.js").UISettings} options.ui */ - constructor ({ userValues, settings, environment, messages, ui }) { + constructor({ userValues, settings, environment, messages, ui }) { this.userValues = userValues; this.settings = settings; this.environment = environment; @@ -12320,7 +12467,7 @@ /** * @param {'page-load' | 'preferences-changed' | 'href-changed'} trigger */ - init (trigger) { + init(trigger) { if (trigger === 'page-load') { this.handleFirstPageLoad(); } else if (trigger === 'preferences-changed') { @@ -12333,13 +12480,13 @@ /** * Special handling of a first-page, an attempt to load our overlay as quickly as possible */ - handleFirstPageLoad () { + handleFirstPageLoad() { // don't continue unless we're in 'alwaysAsk' mode - if ('disabled' in this.userValues.privatePlayerMode) return + if ('disabled' in this.userValues.privatePlayerMode) return; // don't continue if we can't derive valid video params const validParams = VideoParams.forWatchPage(this.environment.getPlayerPageHref()); - if (!validParams) return + if (!validParams) return; /** * If we get here, we know the following: @@ -12361,7 +12508,7 @@ if (style.isConnected) { document.head.removeChild(style); } - } + }; }); /** @@ -12373,18 +12520,18 @@ }, 100); return () => { clearInterval(int); - } + }; }); } /** * @param {import("./util").VideoParams} params */ - addSmallDaxOverlay (params) { + addSmallDaxOverlay(params) { const containerElement = document.querySelector(this.settings.selectors.videoElementContainer); if (!containerElement || !(containerElement instanceof HTMLElement)) { console.error('no container element'); - return + return; } this.sideEffects.add('adding small dax 🐥 icon overlay', () => { const href = params.toPrivatePlayerUrl(); @@ -12397,14 +12544,14 @@ return () => { icon.destroy(); - } + }; }); } /** * @param {{ignoreCache?: boolean, via?: string}} [opts] */ - watchForVideoBeingAdded (opts = {}) { + watchForVideoBeingAdded(opts = {}) { const params = VideoParams.forWatchPage(this.environment.getPlayerPageHref()); if (!params) { @@ -12416,7 +12563,7 @@ this.destroy(); this.lastVideoId = null; } - return + return; } const conditions = [ @@ -12425,7 +12572,7 @@ // first visit !this.lastVideoId, // new video id - this.lastVideoId && this.lastVideoId !== params.id // different + this.lastVideoId && this.lastVideoId !== params.id, // different ]; if (conditions.some(Boolean)) { @@ -12435,7 +12582,7 @@ const videoElement = document.querySelector(this.settings.selectors.videoElement); const playerContainer = document.querySelector(this.settings.selectors.videoElementContainer); if (!videoElement || !playerContainer) { - return null + return null; } /** @@ -12453,22 +12600,22 @@ * When enabled, just show the small dax icon */ if ('enabled' in userValues.privatePlayerMode) { - return this.addSmallDaxOverlay(params) + return this.addSmallDaxOverlay(params); } if ('alwaysAsk' in userValues.privatePlayerMode) { // if there's a one-time-override (eg: a link from the serp), then do nothing - if (this.environment.hasOneTimeOverride()) return + if (this.environment.hasOneTimeOverride()) return; // should the first video be allowed to play? if (this.ui.allowFirstVideo === true && !this.didAllowFirstVideo) { this.didAllowFirstVideo = true; - return console.count('Allowing the first video') + return console.count('Allowing the first video'); } // if the user previously clicked 'watch here + remember', just add the small dax if (this.userValues.overlayInteracted) { - return this.addSmallDaxOverlay(params) + return this.addSmallDaxOverlay(params); } // if we get here, we're trying to prevent the video playing @@ -12482,24 +12629,22 @@ * @param {Element} targetElement * @param {import("./util").VideoParams} params */ - appendOverlayToPage (targetElement, params) { + appendOverlayToPage(targetElement, params) { this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} or ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { this.messages.sendPixel(new Pixel({ name: 'overlay' })); const controller = new AbortController(); const { environment } = this; if (this.environment.layout === 'mobile') { - const elem = /** @type {DDGVideoOverlayMobile} */(document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); + const elem = /** @type {DDGVideoOverlayMobile} */ (document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); elem.testMode = this.environment.isTestMode(); elem.text = mobileStrings(this.environment.strings); elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); - elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */e) => { - return this.mobileOptOut(e.detail.remember) - .catch(console.error) + elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptOut(e.detail.remember).catch(console.error); }); - elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */e) => { - return this.mobileOptIn(e.detail.remember, params) - .catch(console.error) + elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptIn(e.detail.remember, params).catch(console.error); }); targetElement.appendChild(elem); } else { @@ -12507,7 +12652,7 @@ environment, params, ui: this.ui, - manager: this + manager: this, }); targetElement.appendChild(elem); } @@ -12519,21 +12664,21 @@ document.querySelector(DDGVideoOverlay.CUSTOM_TAG_NAME)?.remove(); document.querySelector(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)?.remove(); controller.abort(); - } + }; }); } /** * Just brute-force calling video.pause() for as long as the user is seeing the overlay. */ - stopVideoFromPlaying () { + stopVideoFromPlaying() { this.sideEffects.add(`pausing the