Source: theme/theme.js

import * as nav from 'bitbucket/util/navbuilder';
import * as server from 'bitbucket/util/server';

const THEME_CONST = Object.freeze({
    defaultTheme: 'LIGHT',
    theme: {
        LIGHT: 'LIGHT',
        DARK: 'DARK',
        MATCHING: 'MATCHING',
    },
    mediaListeners: {
        DARK: '(prefers-color-scheme: dark)',
        LIGHT: '(prefers-color-scheme: light)',
    },
    matchingAttr: 'data-color-mode-auto',
    definitionAttr: 'data-theme',
    colorModeAttr: 'data-color-mode',
    themeClass: 'aui-theme-design-tokens',
    colorMode: {
        LIGHT: {
            light: 'light',
            dark: 'dark',
            colorMode: 'light',
        },
        DARK: {
            dark: 'dark',
            light: 'light',
            colorMode: 'dark',
        },
        MATCHING: {
            light: 'light',
            dark: 'dark',
            colorMode: 'matching',
        },
    },
    settings: {
        payload: {
            DARK: {
                colorMode: 'DARK',
                darkThemeKey: 'dark',
                lightThemeKey: 'light',
            },
            LIGHT: {
                colorMode: 'LIGHT',
                darkThemeKey: 'dark',
                lightThemeKey: 'light',
            },
            MATCHING: {
                colorMode: 'MATCHING',
                darkThemeKey: 'dark',
                lightThemeKey: 'light',
            },
        },
        type: server.method.POST,
        url: nav.rest('atlassian-theme', '1').addPathComponents('user-preferences').build(),
        statusCode: {
            '*': false,
        },
    },
    coverage: {
        urlParam: 'web.colors',
        default: '#ffab00ff',
    },
    events: {
        BROADCAST: 'THEME_BROADCAST',
        REQUEST: 'THEME_REQUEST',
        RESPONSE: 'THEME_RESPONSE',
    },
});

let currentTheme = null;

/**
 * setThemeToAPI does an API call to set theme in user preferences:
 *
 * POST rest/atlassian-theme/1/user-preferences
 *
 * @returns {Promise} - a promise that resolves with the current theme
 */
const setThemeToAPI = (theme) => {
    return new Promise((resolve, reject) => {
        return server
            .rest({
                url: THEME_CONST.settings.url,
                type: THEME_CONST.settings.type,
                statusCode: THEME_CONST.settings.statusCode,
                data: THEME_CONST.settings.payload[theme],
            })
            .then(() => resolve(theme))
            .fail(reject);
    });
};

const setDataTheme = (theme) => {
    const colorMode = THEME_CONST.colorMode[theme];

    // setGlobalTheme is not working inside an iframe, we add it manually as a safety net in case setGlobalTheme fails
    // TODO: remove once setGlobalTheme works in iframe
    document.documentElement.setAttribute(
        THEME_CONST.definitionAttr,
        `light:${colorMode.light} dark:${colorMode.dark} spacing:spacing`
    );

    // setGlobalTheme takes to long to set data-color-mode attributes, we add it manually to prevent
    // flashing while using Match system
    // TODO: remove once setGlobalTheme fixes flashing issue
    document.documentElement.setAttribute(THEME_CONST.colorModeAttr, colorMode.colorMode);

    return window.AJS.DesignTokens.setGlobalTheme(colorMode);
};

/**
 * Sets the required attributes by Atlaskit and AUI to turn ON/OFF their Dark themes
 *
 * @param {Object} theme - Any of the available themes in THEME_CONST.theme
 *
 * @returns {string} theme - current theme
 */
const setBitbucketTheme = (theme) => {
    // prevent setting the theme multiple times when in Light or dark mode
    if (theme !== THEME_CONST.theme.MATCHING && currentTheme === theme) {
        return;
    }

    currentTheme = theme;

    const docElement = document.documentElement;
    const body = window.document.getElementsByTagName('body')[0];
    const isSystemInDarkTheme = window.matchMedia(THEME_CONST.mediaListeners.DARK).matches;

    if (theme === THEME_CONST.theme.MATCHING) {
        theme = isSystemInDarkTheme ? THEME_CONST.theme.DARK : THEME_CONST.theme.LIGHT;
        docElement.setAttribute(THEME_CONST.matchingAttr, '');
    } else {
        docElement.removeAttribute(THEME_CONST.matchingAttr);
    }

    const globalThemePromise = setDataTheme(theme);

    body.classList.add(THEME_CONST.themeClass);

    // Broadcast theme change to iframes
    const iframes = document.getElementsByTagName('iframe');

    Array.from(iframes).forEach((iframe) => {
        iframe.contentWindow.postMessage(
            {
                type: THEME_CONST.events.BROADCAST,
                theme: theme,
            },
            '*'
        );
    });

    return theme;
};

/**
 * Reads the current theme from <html> attributes and sets the corresponding theme.
 */
const updateUserPreferredTheme = () => {
    let theme = THEME_CONST.theme.LIGHT;

    const docElementAttrs = document.documentElement.attributes;
    const definitionAttr = docElementAttrs[THEME_CONST.definitionAttr];
    const matchingAttr = docElementAttrs[THEME_CONST.matchingAttr];

    if (matchingAttr) {
        theme = THEME_CONST.theme.MATCHING;
    } else if (definitionAttr) {
        // colorMode only exists when theme is not MATCHING
        const colorMode = docElementAttrs[THEME_CONST.colorModeAttr].value;
        const definitionValue = definitionAttr.value;

        if (colorMode === THEME_CONST.colorMode.DARK.colorMode) {
            theme = THEME_CONST.theme.DARK;
        }
    }

    return setBitbucketTheme(theme);
};

/**
 * Verifies if the module had initialize, if not it will call updateUserPreferredTheme,
 * it will return the current theme.
 *
 * @returns {string} - current theme
 */
const getUserPreferredTheme = () => {
    return currentTheme || updateUserPreferredTheme();
};

/**
 * Sets a new theme if the theme passed inside data is valid, data is used to simulate
 * a REST call in case this functionality is moved to an API.
 *
 * We use a promise for 'set' to simulate a service call that will be implemented in
 * later releases.
 *
 * Currently it will store the selected theme in client storage.
 *
 * @returns {Promise} - a promise that resolves with the current theme
 */
const setUserPreferredTheme = (data) => {
    if (!data || !THEME_CONST.theme[data.theme]) {
        data = {
            theme: THEME_CONST.defaultTheme,
        };
    }

    return new Promise((resolve) => {
        setThemeToAPI(data.theme);
        setBitbucketTheme(data.theme);
        resolve(data.theme);
    });
};

/**
 * Initialize theme module and adds media listeners.
 */
const init = () => {
    updateUserPreferredTheme();

    Object.values(THEME_CONST.mediaListeners).forEach((mediaListeners) => {
        window.matchMedia(mediaListeners).addEventListener('change', updateUserPreferredTheme);
    });

    // For plugin developers and dark theme quality testing we add a web.colors URL param
    const urlParams = new URLSearchParams(window.location.search);

    if (urlParams.has(THEME_CONST.coverage.urlParam)) {
        window.AJS.DesignTokens.setTestingThemeColor(
            urlParams.get(THEME_CONST.coverage.urlParam) || THEME_CONST.coverage.default
        );
        window.AJS.DesignTokens.enableTestingTheme();
    }
};

define('@bitbucket/internal/feature/theme', Object.freeze({
    DEFINITION_ATTRIBUTE: THEME_CONST.definitionAttr,
    COLOR_MODE_ATTRIBUTE: THEME_CONST.colorModeAttr,
    THEME_EVENTS: THEME_CONST.events,
    THEMES: THEME_CONST.theme,
    DEFAULT_THEME: THEME_CONST.defaultTheme,
    getUserPreferredTheme,
    setUserPreferredTheme,
    init,
}));