Change page title and favicon for unreads. Green for favorite group chats, red for unread mentions or DMs. Closes #100 WOOOO

This commit is contained in:
Jack Kingsman
2026-03-23 21:36:54 -07:00
parent 07928d930c
commit 41400c0528
5 changed files with 457 additions and 1 deletions
+1 -1
View File
@@ -9,8 +9,8 @@
<meta name="theme-color" content="#111419" />
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
<title>RemoteTerm for MeshCore</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
+4
View File
@@ -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') {
+1
View File
@@ -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';
+196
View File
@@ -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<string> | null = null;
export type FaviconBadgeState = 'none' | 'green' | 'red';
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
return Object.entries(unreadCounts).reduce(
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
0
);
}
function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>,
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<string, number>): number {
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
}
export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>,
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<string, number>,
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<string, number>,
mentions: Record<string, boolean>,
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('</svg>');
if (closingTagIndex === -1) {
return baseSvg;
}
const badge = `
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
`;
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
}
async function loadBaseFaviconSvg(): Promise<string> {
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<HTMLLinkElement>(`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<string, number>, 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<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
): void {
const objectUrlRef = useRef<string | null>(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]);
}
+255
View File
@@ -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<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
);
}
describe('useFaviconBadge', () => {
const baseSvg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1000" height="1000"/></svg>';
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
let objectUrlCounter = 0;
let fetchMock: ReturnType<typeof vi.fn>;
let createObjectURLMock: ReturnType<typeof vi.fn>;
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
document.head.innerHTML = `
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
`;
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('<circle cx="750" cy="750" r="220" fill="#ffffff"/>');
expect(svg).toContain('<circle cx="750" cy="750" r="180" fill="#16a34a"/>');
expect(svg).not.toContain('<text');
});
it('derives the unread count and page title', () => {
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<string, number>;
mentions: Record<string, boolean>;
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<string, number>;
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');
});
});