From 41400c0528e06df382f5648ba3aac531533d18b1 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 23 Mar 2026 21:36:54 -0700 Subject: [PATCH] Change page title and favicon for unreads. Green for favorite group chats, red for unread mentions or DMs. Closes #100 WOOOO --- frontend/index.html | 2 +- frontend/src/App.tsx | 4 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useFaviconBadge.ts | 196 +++++++++++++++++ frontend/src/test/useFaviconBadge.test.ts | 255 ++++++++++++++++++++++ 5 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks/useFaviconBadge.ts create mode 100644 frontend/src/test/useFaviconBadge.test.ts diff --git a/frontend/index.html b/frontend/index.html index 216d532..37f1246 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,8 +9,8 @@ RemoteTerm for MeshCore - + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b43110..3e5aef1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import { useConversationNavigation, useRealtimeAppState, useBrowserNotifications, + useFaviconBadge, + useUnreadTitle, useRawPacketStatsSession, } from './hooks'; import { AppShell } from './components/AppShell'; @@ -259,6 +261,8 @@ export function App() { markAllRead, refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); + useFaviconBadge(unreadCounts, mentions, favorites); + useUnreadTitle(unreadCounts, favorites); useEffect(() => { if (activeConversation?.type !== 'channel') { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 828b573..a79a475 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; export { useBrowserNotifications } from './useBrowserNotifications'; +export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge'; export { useRawPacketStatsSession } from './useRawPacketStatsSession'; diff --git a/frontend/src/hooks/useFaviconBadge.ts b/frontend/src/hooks/useFaviconBadge.ts new file mode 100644 index 0000000..2bfade6 --- /dev/null +++ b/frontend/src/hooks/useFaviconBadge.ts @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useRef } from 'react'; + +import type { Favorite } from '../types'; +import { getStateKey } from '../utils/conversationState'; + +const APP_TITLE = 'RemoteTerm for MeshCore'; +const UNREAD_APP_TITLE = 'RemoteTerm'; +const BASE_FAVICON_PATH = '/favicon.svg'; +const GREEN_BADGE_FILL = '#16a34a'; +const RED_BADGE_FILL = '#dc2626'; +const BADGE_CENTER = 750; +const BADGE_OUTER_RADIUS = 220; +const BADGE_INNER_RADIUS = 180; + +let baseFaviconSvgPromise: Promise | null = null; + +export type FaviconBadgeState = 'none' | 'green' | 'red'; + +function getUnreadDirectMessageCount(unreadCounts: Record): number { + return Object.entries(unreadCounts).reduce( + (sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0), + 0 + ); +} + +function getUnreadFavoriteChannelCount( + unreadCounts: Record, + favorites: Favorite[] +): number { + return favorites.reduce( + (sum, favorite) => + sum + + (favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0), + 0 + ); +} + +export function getTotalUnreadCount(unreadCounts: Record): number { + return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); +} + +export function getFavoriteUnreadCount( + unreadCounts: Record, + favorites: Favorite[] +): number { + return favorites.reduce((sum, favorite) => { + const stateKey = getStateKey(favorite.type, favorite.id); + return sum + (unreadCounts[stateKey] || 0); + }, 0); +} + +export function getUnreadTitle( + unreadCounts: Record, + favorites: Favorite[] +): string { + const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites); + if (unreadCount <= 0) { + return APP_TITLE; + } + + const label = unreadCount > 99 ? '99+' : String(unreadCount); + return `(${label}) ${UNREAD_APP_TITLE}`; +} + +export function deriveFaviconBadgeState( + unreadCounts: Record, + mentions: Record, + favorites: Favorite[] +): FaviconBadgeState { + if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) { + return 'red'; + } + + if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) { + return 'green'; + } + + return 'none'; +} + +export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string { + const closingTagIndex = baseSvg.lastIndexOf(''); + if (closingTagIndex === -1) { + return baseSvg; + } + + const badge = ` + + + `; + return `${baseSvg.slice(0, closingTagIndex)}${badge}`; +} + +async function loadBaseFaviconSvg(): Promise { + if (!baseFaviconSvgPromise) { + baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load favicon SVG: ${response.status}`); + } + return response.text(); + }) + .catch((error) => { + baseFaviconSvgPromise = null; + throw error; + }); + } + + return baseFaviconSvgPromise; +} + +function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void { + const links = Array.from(document.head.querySelectorAll(`link[rel="${rel}"]`)); + const targets = links.length > 0 ? links : [document.createElement('link')]; + + for (const link of targets) { + if (!link.parentNode) { + link.rel = rel; + document.head.appendChild(link); + } + link.type = 'image/svg+xml'; + link.href = href; + } +} + +function applyFaviconHref(href: string): void { + upsertFaviconLinks('icon', href); + upsertFaviconLinks('shortcut icon', href); +} + +export function useUnreadTitle(unreadCounts: Record, favorites: Favorite[]): void { + const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]); + + useEffect(() => { + document.title = title; + + return () => { + document.title = APP_TITLE; + }; + }, [title]); +} + +export function useFaviconBadge( + unreadCounts: Record, + mentions: Record, + favorites: Favorite[] +): void { + const objectUrlRef = useRef(null); + const badgeState = useMemo( + () => deriveFaviconBadgeState(unreadCounts, mentions, favorites), + [favorites, mentions, unreadCounts] + ); + + useEffect(() => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + + if (badgeState === 'none') { + applyFaviconHref(BASE_FAVICON_PATH); + return; + } + + const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL; + let cancelled = false; + + void loadBaseFaviconSvg() + .then((baseSvg) => { + if (cancelled) { + return; + } + + const objectUrl = URL.createObjectURL( + new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], { + type: 'image/svg+xml', + }) + ); + objectUrlRef.current = objectUrl; + applyFaviconHref(objectUrl); + }) + .catch(() => { + if (!cancelled) { + applyFaviconHref(BASE_FAVICON_PATH); + } + }); + + return () => { + cancelled = true; + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }; + }, [badgeState]); +} diff --git a/frontend/src/test/useFaviconBadge.test.ts b/frontend/src/test/useFaviconBadge.test.ts new file mode 100644 index 0000000..29070b3 --- /dev/null +++ b/frontend/src/test/useFaviconBadge.test.ts @@ -0,0 +1,255 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + buildBadgedFaviconSvg, + deriveFaviconBadgeState, + getFavoriteUnreadCount, + getUnreadTitle, + getTotalUnreadCount, + useFaviconBadge, + useUnreadTitle, +} from '../hooks/useFaviconBadge'; +import type { Favorite } from '../types'; +import { getStateKey } from '../utils/conversationState'; + +function getIconHref(rel: 'icon' | 'shortcut icon'): string | null { + return ( + document.head.querySelector(`link[rel="${rel}"]`)?.getAttribute('href') ?? null + ); +} + +describe('useFaviconBadge', () => { + const baseSvg = + ''; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + let objectUrlCounter = 0; + let fetchMock: ReturnType; + let createObjectURLMock: ReturnType; + let revokeObjectURLMock: ReturnType; + + beforeEach(() => { + document.head.innerHTML = ` + + + `; + document.title = 'RemoteTerm for MeshCore'; + objectUrlCounter = 0; + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => baseSvg, + }); + createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`); + revokeObjectURLMock = vi.fn(); + + vi.stubGlobal('fetch', fetchMock); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: createObjectURLMock, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: revokeObjectURLMock, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: originalCreateObjectURL, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: originalRevokeObjectURL, + }); + }); + + it('derives badge priority from unread counts, mentions, and favorites', () => { + const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }]; + + expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none'); + expect( + deriveFaviconBadgeState( + { + [getStateKey('channel', 'fav-chan')]: 3, + }, + {}, + favorites + ) + ).toBe('green'); + expect( + deriveFaviconBadgeState( + { + [getStateKey('contact', 'abc')]: 12, + }, + {}, + favorites + ) + ).toBe('red'); + expect( + deriveFaviconBadgeState( + { + [getStateKey('channel', 'fav-chan')]: 1, + }, + { + [getStateKey('channel', 'fav-chan')]: true, + }, + favorites + ) + ).toBe('red'); + }); + + it('builds a dot-only badge into the base svg markup', () => { + const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a'); + + expect(svg).toContain(''); + expect(svg).toContain(''); + expect(svg).not.toContain(' { + expect(getTotalUnreadCount({})).toBe(0); + expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7); + expect(getFavoriteUnreadCount({}, [])).toBe(0); + expect( + getFavoriteUnreadCount( + { + [getStateKey('channel', 'fav-chan')]: 7, + [getStateKey('contact', 'fav-contact')]: 3, + [getStateKey('channel', 'other-chan')]: 9, + }, + [ + { type: 'channel', id: 'fav-chan' }, + { type: 'contact', id: 'fav-contact' }, + ] + ) + ).toBe(10); + expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore'); + expect( + getUnreadTitle( + { + [getStateKey('channel', 'fav-chan')]: 7, + [getStateKey('channel', 'other-chan')]: 9, + }, + [{ type: 'channel', id: 'fav-chan' }] + ) + ).toBe('(7) RemoteTerm'); + expect( + getUnreadTitle( + { + [getStateKey('channel', 'fav-chan')]: 120, + }, + [{ type: 'channel', id: 'fav-chan' }] + ) + ).toBe('(99+) RemoteTerm'); + }); + + it('switches between the base favicon and generated blob badges', async () => { + const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }]; + const { rerender } = renderHook( + ({ + unreadCounts, + mentions, + currentFavorites, + }: { + unreadCounts: Record; + mentions: Record; + currentFavorites: Favorite[]; + }) => useFaviconBadge(unreadCounts, mentions, currentFavorites), + { + initialProps: { + unreadCounts: {}, + mentions: {}, + currentFavorites: favorites, + }, + } + ); + + await waitFor(() => { + expect(getIconHref('icon')).toBe('/favicon.svg'); + expect(getIconHref('shortcut icon')).toBe('/favicon.svg'); + }); + + rerender({ + unreadCounts: { + [getStateKey('channel', 'fav-chan')]: 1, + }, + mentions: {}, + currentFavorites: favorites, + }); + + await waitFor(() => { + expect(getIconHref('icon')).toBe('blob:generated-1'); + expect(getIconHref('shortcut icon')).toBe('blob:generated-1'); + }); + + rerender({ + unreadCounts: { + [getStateKey('contact', 'dm-key')]: 12, + }, + mentions: {}, + currentFavorites: favorites, + }); + + await waitFor(() => { + expect(getIconHref('icon')).toBe('blob:generated-2'); + expect(getIconHref('shortcut icon')).toBe('blob:generated-2'); + }); + + rerender({ + unreadCounts: {}, + mentions: {}, + currentFavorites: favorites, + }); + + await waitFor(() => { + expect(getIconHref('icon')).toBe('/favicon.svg'); + expect(getIconHref('shortcut icon')).toBe('/favicon.svg'); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(createObjectURLMock).toHaveBeenCalledTimes(2); + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1'); + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2'); + }); + + it('writes unread counts into the page title', () => { + const { rerender, unmount } = renderHook( + ({ + unreadCounts, + favorites, + }: { + unreadCounts: Record; + favorites: Favorite[]; + }) => useUnreadTitle(unreadCounts, favorites), + { + initialProps: { + unreadCounts: {}, + favorites: [{ type: 'channel', id: 'fav-chan' }], + }, + } + ); + + expect(document.title).toBe('RemoteTerm for MeshCore'); + + rerender({ + unreadCounts: { + [getStateKey('channel', 'fav-chan')]: 4, + [getStateKey('contact', 'dm-key')]: 2, + }, + favorites: [{ type: 'channel', id: 'fav-chan' }], + }); + + expect(document.title).toBe('(4) RemoteTerm'); + + unmount(); + + expect(document.title).toBe('RemoteTerm for MeshCore'); + }); +});