From 6b79ac1192faa3f681c13b02fd59c22e632cb77b Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Mon, 26 Aug 2024 14:53:42 -0500 Subject: [PATCH] Add favicon theme listener This PR changes the way we update our favicon based on system theme preference. Currently, we rely on the media attr in the link tag to obey the system preference, but some browsers don't seem to support that very well. Instead, we will now add a listener when Teleport loads to the theme and set the favicon dynamically. Note: this means the favicon will match the theme of the system/browser and NOT the theme of the page (which can be different, depending on user). The goal of the favicon theme switch is to make the favicon visible based on the browser's theme, rather than necessarily matching the page theme. This can eventually be expanded upon to be included in a theme switch when we have a user settings page that allows "system" as a theme choice --- .../design/src/ThemeProvider/index.tsx | 13 ++++ web/packages/teleport/index.html | 7 -- web/packages/teleport/src/Teleport.tsx | 28 +++++++- .../userPreferences/userPreferences.test.ts | 66 ++++++++++++++++++- 4 files changed, 104 insertions(+), 10 deletions(-) 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);