Merge pull request #3084 from AHOHNMYC/js-helpers-polyfills
JS refactoring part 2: helper functions, poyfillspull/3158/head
commit
2313ca8f72
@ -0,0 +1,249 @@
|
||||
'use strict';
|
||||
// Contains only auxiliary methods
|
||||
// May be included and executed unlimited number of times without any consequences
|
||||
|
||||
// Polyfills for IE11
|
||||
Array.prototype.find = Array.prototype.find || function (condition) {
|
||||
return this.filter(condition)[0];
|
||||
};
|
||||
Array.from = Array.from || function (source) {
|
||||
return Array.prototype.slice.call(source);
|
||||
};
|
||||
NodeList.prototype.forEach = NodeList.prototype.forEach || function (callback) {
|
||||
Array.from(this).forEach(callback);
|
||||
};
|
||||
String.prototype.includes = String.prototype.includes || function (searchString) {
|
||||
return this.indexOf(searchString) >= 0;
|
||||
};
|
||||
String.prototype.startsWith = String.prototype.startsWith || function (prefix) {
|
||||
return this.substr(0, prefix.length) === prefix;
|
||||
};
|
||||
Math.sign = Math.sign || function(x) {
|
||||
x = +x;
|
||||
if (!x) return x; // 0 and NaN
|
||||
return x > 0 ? 1 : -1;
|
||||
};
|
||||
if (!window.hasOwnProperty('HTMLDetailsElement') && !window.hasOwnProperty('mockHTMLDetailsElement')) {
|
||||
window.mockHTMLDetailsElement = true;
|
||||
const style = 'details:not([open]) > :not(summary) {display: none}';
|
||||
document.head.appendChild(document.createElement('style')).textContent = style;
|
||||
|
||||
addEventListener('click', function (e) {
|
||||
if (e.target.nodeName !== 'SUMMARY') return;
|
||||
const details = e.target.parentElement;
|
||||
if (details.hasAttribute('open'))
|
||||
details.removeAttribute('open');
|
||||
else
|
||||
details.setAttribute('open', '');
|
||||
});
|
||||
}
|
||||
|
||||
// Monstrous global variable for handy code
|
||||
// Includes: clamp, xhr, storage.{get,set,remove}
|
||||
window.helpers = window.helpers || {
|
||||
/**
|
||||
* https://en.wikipedia.org/wiki/Clamping_(graphics)
|
||||
* @param {Number} num Source number
|
||||
* @param {Number} min Low border
|
||||
* @param {Number} max High border
|
||||
* @returns {Number} Clamped value
|
||||
*/
|
||||
clamp: function (num, min, max) {
|
||||
if (max < min) {
|
||||
var t = max; max = min; min = t; // swap max and min
|
||||
}
|
||||
|
||||
if (max < num)
|
||||
return max;
|
||||
if (min > num)
|
||||
return min;
|
||||
return num;
|
||||
},
|
||||
|
||||
/** @private */
|
||||
_xhr: function (method, url, options, callbacks) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
|
||||
// Default options
|
||||
xhr.responseType = 'json';
|
||||
xhr.timeout = 10000;
|
||||
// Default options redefining
|
||||
if (options.responseType)
|
||||
xhr.responseType = options.responseType;
|
||||
if (options.timeout)
|
||||
xhr.timeout = options.timeout;
|
||||
|
||||
if (method === 'POST')
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
|
||||
// better than onreadystatechange because of 404 codes https://stackoverflow.com/a/36182963
|
||||
xhr.onloadend = function () {
|
||||
if (xhr.status === 200) {
|
||||
if (callbacks.on200) {
|
||||
// fix for IE11. It doesn't convert response to JSON
|
||||
if (xhr.responseType === '' && typeof(xhr.response) === 'string')
|
||||
callbacks.on200(JSON.parse(xhr.response));
|
||||
else
|
||||
callbacks.on200(xhr.response);
|
||||
}
|
||||
} else {
|
||||
// handled by onerror
|
||||
if (xhr.status === 0) return;
|
||||
|
||||
if (callbacks.onNon200)
|
||||
callbacks.onNon200(xhr);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.ontimeout = function () {
|
||||
if (callbacks.onTimeout)
|
||||
callbacks.onTimeout(xhr);
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
if (callbacks.onError)
|
||||
callbacks.onError(xhr);
|
||||
};
|
||||
|
||||
if (options.payload)
|
||||
xhr.send(options.payload);
|
||||
else
|
||||
xhr.send();
|
||||
},
|
||||
/** @private */
|
||||
_xhrRetry: function(method, url, options, callbacks) {
|
||||
if (options.retries <= 0) {
|
||||
console.warn('Failed to pull', options.entity_name);
|
||||
if (callbacks.onTotalFail)
|
||||
callbacks.onTotalFail();
|
||||
return;
|
||||
}
|
||||
helpers._xhr(method, url, options, callbacks);
|
||||
},
|
||||
/**
|
||||
* @callback callbackXhrOn200
|
||||
* @param {Object} response - xhr.response
|
||||
*/
|
||||
/**
|
||||
* @callback callbackXhrError
|
||||
* @param {XMLHttpRequest} xhr
|
||||
*/
|
||||
/**
|
||||
* @param {'GET'|'POST'} method - 'GET' or 'POST'
|
||||
* @param {String} url - URL to send request to
|
||||
* @param {Object} options - other XHR options
|
||||
* @param {XMLHttpRequestBodyInit} [options.payload=null] - payload for POST-requests
|
||||
* @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [options.responseType=json]
|
||||
* @param {Number} [options.timeout=10000]
|
||||
* @param {Number} [options.retries=1]
|
||||
* @param {String} [options.entity_name='unknown'] - string to log
|
||||
* @param {Number} [options.retry_timeout=1000]
|
||||
* @param {Object} callbacks - functions to execute on events fired
|
||||
* @param {callbackXhrOn200} [callbacks.on200]
|
||||
* @param {callbackXhrError} [callbacks.onNon200]
|
||||
* @param {callbackXhrError} [callbacks.onTimeout]
|
||||
* @param {callbackXhrError} [callbacks.onError]
|
||||
* @param {callbackXhrError} [callbacks.onTotalFail] - if failed after all retries
|
||||
*/
|
||||
xhr: function(method, url, options, callbacks) {
|
||||
if (!options.retries || options.retries <= 1) {
|
||||
helpers._xhr(method, url, options, callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.entity_name) options.entity_name = 'unknown';
|
||||
if (!options.retry_timeout) options.retry_timeout = 1000;
|
||||
const retries_total = options.retries;
|
||||
let currentTry = 1;
|
||||
|
||||
const retry = function () {
|
||||
console.warn('Pulling ' + options.entity_name + ' failed... ' + (currentTry++) + '/' + retries_total);
|
||||
setTimeout(function () {
|
||||
options.retries--;
|
||||
helpers._xhrRetry(method, url, options, callbacks);
|
||||
}, options.retry_timeout);
|
||||
};
|
||||
|
||||
// Pack retry() call into error handlers
|
||||
callbacks._onError = callbacks.onError;
|
||||
callbacks.onError = function (xhr) {
|
||||
if (callbacks._onError)
|
||||
callbacks._onError(xhr);
|
||||
retry();
|
||||
};
|
||||
callbacks._onTimeout = callbacks.onTimeout;
|
||||
callbacks.onTimeout = function (xhr) {
|
||||
if (callbacks._onTimeout)
|
||||
callbacks._onTimeout(xhr);
|
||||
retry();
|
||||
};
|
||||
|
||||
helpers._xhrRetry(method, url, options, callbacks);
|
||||
},
|
||||
|
||||
/**
|
||||
* @typedef {Object} invidiousStorage
|
||||
* @property {(key:String) => Object} get
|
||||
* @property {(key:String, value:Object)} set
|
||||
* @property {(key:String)} remove
|
||||
*/
|
||||
|
||||
/**
|
||||
* Universal storage, stores and returns JS objects. Uses inside localStorage or cookies
|
||||
* @type {invidiousStorage}
|
||||
*/
|
||||
storage: (function () {
|
||||
// access to localStorage throws exception in Tor Browser, so try is needed
|
||||
let localStorageIsUsable = false;
|
||||
try{localStorageIsUsable = !!localStorage.setItem;}catch(e){}
|
||||
|
||||
if (localStorageIsUsable) {
|
||||
return {
|
||||
get: function (key) {
|
||||
if (!localStorage[key]) return;
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(localStorage[key]));
|
||||
} catch(e) {
|
||||
// Erase non parsable value
|
||||
helpers.storage.remove(key);
|
||||
}
|
||||
},
|
||||
set: function (key, value) { localStorage[key] = encodeURIComponent(JSON.stringify(value)); },
|
||||
remove: function (key) { localStorage.removeItem(key); }
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: fire 'storage' event for cookies
|
||||
console.info('Storage: localStorage is disabled or unaccessible. Cookies used as fallback');
|
||||
return {
|
||||
get: function (key) {
|
||||
const cookiePrefix = key + '=';
|
||||
function findCallback(cookie) {return cookie.startsWith(cookiePrefix);}
|
||||
const matchedCookie = document.cookie.split('; ').find(findCallback);
|
||||
if (matchedCookie) {
|
||||
const cookieBody = matchedCookie.replace(cookiePrefix, '');
|
||||
if (cookieBody.length === 0) return;
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(cookieBody));
|
||||
} catch(e) {
|
||||
// Erase non parsable value
|
||||
helpers.storage.remove(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
set: function (key, value) {
|
||||
const cookie_data = encodeURIComponent(JSON.stringify(value));
|
||||
|
||||
// Set expiration in 2 year
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear()+2);
|
||||
|
||||
document.cookie = key + '=' + cookie_data + '; expires=' + date.toGMTString();
|
||||
},
|
||||
remove: function (key) {
|
||||
document.cookie = key + '=; Max-Age=0';
|
||||
}
|
||||
};
|
||||
})()
|
||||
};
|
@ -1,90 +1,44 @@
|
||||
'use strict';
|
||||
var toggle_theme = document.getElementById('toggle_theme');
|
||||
toggle_theme.href = 'javascript:void(0);';
|
||||
toggle_theme.href = 'javascript:void(0)';
|
||||
|
||||
toggle_theme.addEventListener('click', function () {
|
||||
var dark_mode = document.body.classList.contains('light-theme');
|
||||
|
||||
var url = '/toggle_theme?redirect=false';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'json';
|
||||
xhr.timeout = 10000;
|
||||
xhr.open('GET', url, true);
|
||||
|
||||
set_mode(dark_mode);
|
||||
try {
|
||||
window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light');
|
||||
} catch (e) {}
|
||||
const STORAGE_KEY_THEME = 'dark_mode';
|
||||
const THEME_DARK = 'dark';
|
||||
const THEME_LIGHT = 'light';
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'dark_mode') {
|
||||
update_mode(e.newValue);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
const dark_mode = document.getElementById('dark_mode_pref').textContent;
|
||||
try {
|
||||
// Update localStorage if dark mode preference changed on preferences page
|
||||
window.localStorage.setItem('dark_mode', dark_mode);
|
||||
} catch (e) {}
|
||||
update_mode(dark_mode);
|
||||
// TODO: theme state controlled by system
|
||||
toggle_theme.addEventListener('click', function () {
|
||||
const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK;
|
||||
const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK;
|
||||
setTheme(newTheme);
|
||||
helpers.storage.set(STORAGE_KEY_THEME, newTheme);
|
||||
helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {});
|
||||
});
|
||||
|
||||
|
||||
var darkScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var lightScheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
|
||||
darkScheme.addListener(scheme_switch);
|
||||
lightScheme.addListener(scheme_switch);
|
||||
|
||||
function scheme_switch (e) {
|
||||
// ignore this method if we have a preference set
|
||||
try {
|
||||
if (localStorage.getItem('dark_mode')) {
|
||||
return;
|
||||
}
|
||||
} catch (exception) {}
|
||||
if (e.matches) {
|
||||
if (e.media.includes('dark')) {
|
||||
set_mode(true);
|
||||
} else if (e.media.includes('light')) {
|
||||
set_mode(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function set_mode (bool) {
|
||||
if (bool) {
|
||||
// dark
|
||||
toggle_theme.children[0].setAttribute('class', 'icon ion-ios-sunny');
|
||||
document.body.classList.remove('no-theme');
|
||||
document.body.classList.remove('light-theme');
|
||||
document.body.classList.add('dark-theme');
|
||||
/** @param {THEME_DARK|THEME_LIGHT} theme */
|
||||
function setTheme(theme) {
|
||||
// By default body element has .no-theme class that uses OS theme via CSS @media rules
|
||||
// It rewrites using hard className below
|
||||
if (theme === THEME_DARK) {
|
||||
toggle_theme.children[0].className = 'icon ion-ios-sunny';
|
||||
document.body.className = 'dark-theme';
|
||||
} else {
|
||||
// light
|
||||
toggle_theme.children[0].setAttribute('class', 'icon ion-ios-moon');
|
||||
document.body.classList.remove('no-theme');
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.body.classList.add('light-theme');
|
||||
toggle_theme.children[0].className = 'icon ion-ios-moon';
|
||||
document.body.className = 'light-theme';
|
||||
}
|
||||
}
|
||||
|
||||
function update_mode (mode) {
|
||||
if (mode === 'true' /* for backwards compatibility */ || mode === 'dark') {
|
||||
// If preference for dark mode indicated
|
||||
set_mode(true);
|
||||
}
|
||||
else if (mode === 'false' /* for backwards compatibility */ || mode === 'light') {
|
||||
// If preference for light mode indicated
|
||||
set_mode(false);
|
||||
}
|
||||
else if (document.getElementById('dark_mode_pref').textContent === '' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
// If no preference indicated here and no preference indicated on the preferences page (backend), but the browser tells us that the operating system has a dark theme
|
||||
set_mode(true);
|
||||
// Handles theme change event caused by other tab
|
||||
addEventListener('storage', function (e) {
|
||||
if (e.key === STORAGE_KEY_THEME)
|
||||
setTheme(helpers.storage.get(STORAGE_KEY_THEME));
|
||||
});
|
||||
|
||||
// Set theme from preferences on page load
|
||||
addEventListener('DOMContentLoaded', function () {
|
||||
const prefTheme = document.getElementById('dark_mode_pref').textContent;
|
||||
if (prefTheme) {
|
||||
setTheme(prefTheme);
|
||||
helpers.storage.set(STORAGE_KEY_THEME, prefTheme);
|
||||
}
|
||||
// else do nothing, falling back to the mode defined by the `dark_mode` preference on the preferences page (backend)
|
||||
}
|
||||
});
|
||||
|
Loading…
Reference in New Issue