From 2d5a3efc128b0222296dbe6474396d69bdd73b50 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 23 Jun 2025 11:09:26 +0200 Subject: [PATCH] [web] Add compilation indicator favicon (#25990) * Import changes from Hackathon https://github.com/overleaf/internal/pull/24501 * Update compile status: allow errors * Update favicons. Use the ones from Figma * Optimize and reuse path from favicon.svg * Clear status favicon after 5s on active tab * Rename hook from useCompileNotification to useStatusFavicon * Add tests * Revert changes to favicon.svg * Query favicon on document.head GitOrigin-RevId: 3972b1981abaf6c80273e0ed5b1bc05eb51bd689 --- .../ide-react/components/layout/ide-page.tsx | 2 + .../ide-react/hooks/use-status-favicon.ts | 75 ++++++++++ services/web/public/favicon-compiled.svg | 9 ++ services/web/public/favicon-compiling.svg | 9 ++ services/web/public/favicon-error.svg | 9 ++ .../ide-react/unit/use-status-favicon.test.ts | 131 ++++++++++++++++++ 6 files changed, 235 insertions(+) create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts create mode 100644 services/web/public/favicon-compiled.svg create mode 100644 services/web/public/favicon-compiling.svg create mode 100644 services/web/public/favicon-error.svg create mode 100644 services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 6a03d5b205..488d5476a7 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx @@ -16,6 +16,7 @@ import { } from '@/features/ide-redesign/utils/new-editor-utils' import EditorSurvey from '../editor-survey' import { useFeatureFlag } from '@/shared/context/split-test-context' +import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon' const MainLayoutNew = lazy( () => import('@/features/ide-redesign/components/main-layout') @@ -30,6 +31,7 @@ export default function IdePage() { useEditingSessionHeartbeat() // send a batched event when user is active useRegisterUserActivity() // record activity and ensure connection when user is active useHasLintingError() // pass editor:lint hasLintingError to the compiler + useStatusFavicon() // update the favicon based on the compile status const newEditor = useIsNewEditorEnabled() const canAccessNewEditor = canUseNewEditor() diff --git a/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts b/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts new file mode 100644 index 0000000000..c65d49e042 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts @@ -0,0 +1,75 @@ +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { useEffect, useState } from 'react' +import usePreviousValue from '@/shared/hooks/use-previous-value' + +const RESET_AFTER_MS = 5_000 + +const COMPILE_ICONS = { + ERROR: '/favicon-error.svg', + COMPILING: '/favicon-compiling.svg', + COMPILED: '/favicon-compiled.svg', + UNCOMPILED: '/favicon.svg', +} + +type CompileStatus = keyof typeof COMPILE_ICONS + +const useCompileStatus = (): CompileStatus => { + const compileContext = useCompileContext() + if (compileContext.uncompiled) return 'UNCOMPILED' + if (compileContext.compiling) return 'COMPILING' + if (compileContext.error) return 'ERROR' + return 'COMPILED' +} + +const removeFavicon = () => { + const existingFavicons = document.head.querySelectorAll( + "link[rel='icon']" + ) as NodeListOf + existingFavicons.forEach(favicon => { + if (favicon.href.endsWith('.svg')) favicon.parentNode?.removeChild(favicon) + }) +} + +const updateFavicon = (status: CompileStatus = 'UNCOMPILED') => { + removeFavicon() + const linkElement = document.createElement('link') + linkElement.rel = 'icon' + linkElement.href = COMPILE_ICONS[status] + linkElement.type = 'image/svg+xml' + linkElement.setAttribute('data-compile-status', 'true') + document.head.appendChild(linkElement) +} + +const isActive = () => !document.hidden + +const useIsWindowActive = () => { + const [isWindowActive, setIsWindowActive] = useState(isActive()) + useEffect(() => { + const handleVisibilityChange = () => setIsWindowActive(isActive()) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + return isWindowActive +} + +export const useStatusFavicon = () => { + const compileStatus = useCompileStatus() + const previousCompileStatus = usePreviousValue(compileStatus) + const isWindowActive = useIsWindowActive() + + useEffect(() => { + if (previousCompileStatus !== compileStatus) { + return updateFavicon(compileStatus) + } + + if ( + isWindowActive && + (compileStatus === 'COMPILED' || compileStatus === 'ERROR') + ) { + const timeout = setTimeout(updateFavicon, RESET_AFTER_MS) + return () => clearTimeout(timeout) + } + }, [compileStatus, isWindowActive, previousCompileStatus]) +} diff --git a/services/web/public/favicon-compiled.svg b/services/web/public/favicon-compiled.svg new file mode 100644 index 0000000000..8bee787bb2 --- /dev/null +++ b/services/web/public/favicon-compiled.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-compiling.svg b/services/web/public/favicon-compiling.svg new file mode 100644 index 0000000000..fed675637c --- /dev/null +++ b/services/web/public/favicon-compiling.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-error.svg b/services/web/public/favicon-error.svg new file mode 100644 index 0000000000..5b88401356 --- /dev/null +++ b/services/web/public/favicon-error.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts new file mode 100644 index 0000000000..f3a9294eb1 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { renderHook } from '@testing-library/react' +import * as CompileContext from '@/shared/context/detach-compile-context' + +import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon' + +type Compilation = { uncompiled: boolean; compiling: boolean; error: boolean } + +describe('useStatusFavicon', function () { + let mockUseDetachCompileContext: sinon.SinonStub + let clock: sinon.SinonFakeTimers + let originalHidden: PropertyDescriptor | undefined + + const setCompilation = (compileContext: Compilation) => { + mockUseDetachCompileContext.returns(compileContext) + } + const setHidden = (hidden: boolean) => { + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: hidden, + }) + document.dispatchEvent(new Event('visibilitychange')) + } + + const getFaviconElements = () => + document.querySelectorAll('link[data-compile-status="true"]') + + const getCurrentFaviconHref = () => { + const favicon = document.querySelector( + 'link[data-compile-status="true"]' + ) as HTMLLinkElement + return favicon?.href || null + } + + beforeEach(function () { + mockUseDetachCompileContext = sinon.stub( + CompileContext, + 'useDetachCompileContext' + ) + + // Mock timers for timeout testing + clock = sinon.useFakeTimers() + + // Store original document.hidden descriptor + originalHidden = Object.getOwnPropertyDescriptor( + Document.prototype, + 'hidden' + ) + + // Clean up any existing favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + afterEach(function () { + sinon.restore() + clock.restore() + + // Restore original document.hidden + if (originalHidden) { + Object.defineProperty(document, 'hidden', originalHidden) + } + + // Clean up favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + it('updates favicon to reflect status: UNCOMPILED', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('updates favicon to reflect status: COMPILING', function () { + setCompilation({ uncompiled: false, compiling: true, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) + + it('updates favicon to reflect status: COMPILED', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('updates favicon to reflect status: ERROR', function () { + setCompilation({ uncompiled: false, compiling: false, error: true }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-error.svg') + }) + + it('keeps the COMPILED favicon for 5 seconds when the window is active', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(false) + rerender() + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(4500) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(1000) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('keeps the COMPILED favicon forever when the window is hidden', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(true) + rerender() + + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(90000) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('should only have one favicon element at a time', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + + setCompilation({ uncompiled: false, compiling: true, error: false }) + rerender() + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) +})