import React from 'dom-chef'; import cache from 'webext-storage-cache'; import select from 'select-dom'; import domLoaded from 'dom-loaded'; import stripIndent from 'strip-indent'; import {Promisable} from 'type-fest'; import elementReady from 'element-ready'; import compareVersions from 'tiny-version-compare'; import * as pageDetect from 'github-url-detection'; import onNewComments from '../github-events/on-new-comments'; import optionsStorage, {RGHOptions} from '../options-storage'; type BooleanFunction = () => boolean; type CallerFunction = (callback: VoidFunction) => void; type FeatureInit = () => Promisable; interface FeatureLoader extends Partial { /** This only adds the shortcut to the help screen, it doesn't enable it. @default {} */ shortcuts?: Record; /** Whether to wait for DOM ready before running `init`. `false` makes `init` run right as soon as `body` is found. @default true */ awaitDomReady?: false; /** When pressing the back button, the DOM and listeners are still there, so normally `init` isn’t called again. If this is true, it’s called anyway. @default false */ repeatOnBackButton?: true; /** When true, don’t run the `init` on page load but only add the `additionalListeners`. @default false */ onlyAdditionalListeners?: true; init: FeatureInit; // Repeated here because this interface is Partial<> } interface InternalRunConfig { include: BooleanFunction[]; exclude: BooleanFunction[]; init: FeatureInit; deinit?: () => void; additionalListeners: CallerFunction[]; onlyAdditionalListeners: boolean; } let log: typeof console.log; function logError(id: FeatureID, error: Error | string | unknown, ...extras: unknown[]): void { if (error instanceof TypeError && error.message === 'Object(...)(...) is null') { error.message = 'The element wasn’t found, the selector needs to be updated.'; } const message = error instanceof Error ? error.message : String(error); if (message.includes('token')) { console.log(`ℹ️ Refined GitHub → ${id} →`, message); return; } // Don't change this to `throw Error` because Firefox doesn't show extensions' errors in the console. // Use `return` after calling this function. console.error( `❌ Refined GitHub → ${id} →`, error, ...extras, stripIndent(` Search issue: https://github.com/sindresorhus/refined-github/issues?q=is%3Aissue+${encodeURIComponent(message)} Open an issue: https://github.com/sindresorhus/refined-github/issues/new?labels=bug&template=bug_report.md&title=${encodeURIComponent(`\`${id}\`: ${message}`)} `) ); } // Rule assumes we don't want to leave it pending: // eslint-disable-next-line no-async-promise-executor const globalReady: Promise = new Promise(async resolve => { await elementReady('body'); if (pageDetect.is500()) { return; } if (document.title === 'Confirm password') { return; } if (document.body.classList.contains('logged-out')) { console.warn('%cRefined GitHub%c is only expected to work when you’re logged in to GitHub.', 'font-weight: bold', ''); } if (select.exists('html.refined-github')) { console.warn('Refined GitHub has been loaded twice. If you didn’t install the developer version, this may be a bug. Please report it to: https://github.com/sindresorhus/refined-github/issues/565'); return; } document.documentElement.classList.add('refined-github'); // Options defaults const options = await optionsStorage.getAll(); const hotfix = browser.runtime.getManifest().version === '0.0.0' || await cache.get('hotfix'); // Ignores the cache when loaded locally // If features are remotely marked as "seriously breaking" by the maintainers, disable them without having to wait for proper updates to propagate #3529 void checkForHotfixes(); Object.assign(options, hotfix); if (options.customCSS.trim().length > 0) { document.head.append(); } // Create logging function log = options.logging ? console.log : () => {/* No logging */}; resolve(options); }); const setupPageLoad = async (id: FeatureID, config: InternalRunConfig): Promise => { const {include, exclude, init, deinit, additionalListeners, onlyAdditionalListeners} = config; // If every `include` is false and no `exclude` is true, don’t run the feature if (include.every(c => !c()) || exclude.some(c => c())) { return; } const runFeature = async (): Promise => { try { // Features can return `false` when they decide not to run on the current page // Also the condition avoids logging the fake feature added for `has-rgh` if (await init() !== false && id !== __filebasename) { log('✅', id); } } catch (error: unknown) { logError(id, error); } if (deinit) { document.addEventListener('pjax:start', deinit, { once: true }); } }; if (!onlyAdditionalListeners) { await runFeature(); } await domLoaded; // Listeners likely need to work on the whole page for (const listener of additionalListeners) { listener(runFeature); } }; const checkForHotfixes = cache.function(async () => { // The explicit endpoint is necessary because it shouldn't change on GHE const request = await fetch('https://api.github.com/repos/sindresorhus/refined-github/contents/hotfix.json?ref=hotfix'); const response = await request.json(); const hotfixes: AnyObject | false = JSON.parse(atob(response.content)); // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- https://github.com/typescript-eslint/typescript-eslint/issues/1893 if (hotfixes && hotfixes.unaffected) { const currentVersion = browser.runtime.getManifest().version; if (compareVersions(hotfixes.unaffected, currentVersion) < 1) { return {}; } } return hotfixes; }, { maxAge: {hours: 6}, cacheKey: () => 'hotfix' }); const shortcutMap = new Map(); const defaultPairs = new Map([ [pageDetect.hasComments, onNewComments] ]); function enforceDefaults( id: FeatureID, include: InternalRunConfig['include'], additionalListeners: InternalRunConfig['additionalListeners'] ): void { for (const [detection, listener] of defaultPairs) { if (!include.includes(detection)) { continue; } if (additionalListeners.includes(listener)) { console.error(`❌ ${id} → If you use \`${detection.name}\` you don’t need to specify \`${listener.name}\``); } else { additionalListeners.push(listener); } } } /** Register a new feature */ const add = async (id: FeatureID, ...loaders: FeatureLoader[]): Promise => { /* Feature filtering and running */ const options = await globalReady; if (options[`feature:${id}`] === false) { log('↩️', 'Skipping', id); return; } for (const loader of loaders) { // Input defaults and validation const { shortcuts = {}, include = [() => true], // Default: every page exclude = [], // Default: nothing init, deinit, awaitDomReady = true, repeatOnBackButton = false, onlyAdditionalListeners = false, additionalListeners = [] } = loader; // Register feature shortcuts for (const [hotkey, description] of Object.entries(shortcuts)) { shortcutMap.set(hotkey, description); } // 404 pages should only run 404-only features if (pageDetect.is404() && !include.includes(pageDetect.is404)) { continue; } enforceDefaults(id, include, additionalListeners); const details = {include, exclude, init, deinit, additionalListeners, onlyAdditionalListeners}; if (awaitDomReady) { (async () => { await domLoaded; await setupPageLoad(id, details); })(); } else { void setupPageLoad(id, details); } document.addEventListener('pjax:end', () => { if (repeatOnBackButton || !select.exists('has-rgh')) { void setupPageLoad(id, details); } }); } }; /* When navigating back and forth in history, GitHub will preserve the DOM changes; This means that the old features will still be on the page and don't need to re-run. This marks each as "processed" */ void add(__filebasename, { init: async () => { // `await` kicks it to the next tick, after the other features have checked for 'has-rgh', so they can run once. await Promise.resolve(); select('#js-repo-pjax-container, #js-pjax-container')?.append(); } }); const features = { add, error: logError, shortcutMap }; export default features;