diff --git a/web/packages/design/src/ThemeProvider/index.tsx b/web/packages/design/src/ThemeProvider/index.tsx index 4e064080dab66..7cd3db621e618 100644 --- a/web/packages/design/src/ThemeProvider/index.tsx +++ b/web/packages/design/src/ThemeProvider/index.tsx @@ -67,6 +67,19 @@ export function getPrefersDark(): boolean { ); } +export function updateFavicon() { + const darkModePreferred = getPrefersDark(); + const favicon = document.querySelector('link[rel="icon"]'); + + if (favicon instanceof HTMLLinkElement) { + if (darkModePreferred) { + favicon.href = '/app/favicon-dark.png'; + } else { + favicon.href = '/app/favicon-light.png'; + } + } +} + const ThemeProvider = props => { const [themePreference, setThemePreference] = useState( storageService.getThemePreference() diff --git a/web/packages/teleport/index.html b/web/packages/teleport/index.html index f6c081bbe15bc..34d6ce56782c2 100644 --- a/web/packages/teleport/index.html +++ b/web/packages/teleport/index.html @@ -12,13 +12,6 @@ href="/app/favicon-light.png" rel="icon" type="image/png" - media="(prefers-color-scheme: light)" - /> - diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index b5c15090b3d00..83158e8e70062 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -import React, { Suspense } from 'react'; -import ThemeProvider from 'design/ThemeProvider'; +import React, { Suspense, useEffect } from 'react'; +import ThemeProvider, { updateFavicon } from 'design/ThemeProvider'; import { Route, Router, Switch } from 'teleport/components/Router'; import { CatchError } from 'teleport/components/CatchError'; @@ -55,6 +55,30 @@ const Teleport: React.FC = props => { const { ctx, history } = props; const createPublicRoutes = props.renderPublicRoutes || publicOSSRoutes; const createPrivateRoutes = props.renderPrivateRoutes || privateOSSRoutes; + // update the favicon based on the system pref, and listen if it changes + // overtime. + // TODO(avatus) this can be expanded upon eventually to handle the entire theme + // once we have a user settings page that allows users to properly set their theme + // to respect the system prefs. We only update the favicon here because the selected theme + // of the page doesn't necessarily match the theme of the browser, which is what we + // are trying to match. + useEffect(() => { + updateFavicon(); + + const colorSchemeQueryList = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + + const colorSchemeListener = () => { + updateFavicon(); + }; + + colorSchemeQueryList.addEventListener('change', colorSchemeListener); + + return () => { + colorSchemeQueryList.removeEventListener('change', colorSchemeListener); + }; + }, []); return ( diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts index 0595e3a19ecbd..e4b8ed3c30b8f 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts @@ -16,7 +16,11 @@ * along with this program. If not, see . */ -import { getCurrentTheme, getNextTheme } from 'design/ThemeProvider'; +import { + getCurrentTheme, + getNextTheme, + updateFavicon, +} from 'design/ThemeProvider'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; @@ -90,6 +94,66 @@ test('getCurrentTheme', () => { expect(currentTheme).toBe(Theme.DARK); }); +describe('updateFavicon', () => { + let originalMatchMedia: typeof window.matchMedia; + + beforeAll(() => { + originalMatchMedia = window.matchMedia; + }); + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + beforeEach(() => { + document.body.innerHTML = ''; + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = '/initial-favicon.png'; + document.head.appendChild(link); + }); + + test('set dark favicon when dark theme is preferred', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + updateFavicon(); + + const favicon = document.querySelector( + 'link[rel="icon"]' + ) as HTMLLinkElement; + expect(favicon.href).toContain('/app/favicon-dark.png'); + }); + + test('set light favicon when light theme is preferred', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query !== '(prefers-color-scheme: dark)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + updateFavicon(); + + const favicon = document.querySelector( + 'link[rel="icon"]' + ) as HTMLLinkElement; + expect(favicon.href).toContain('/app/favicon-light.png'); + }); +}); + test('getNextTheme', () => { mockMatchMediaWindow('dark'); let nextTheme = getNextTheme(Theme.UNSPECIFIED);