@djangocfg/ui-tools 2.1.394 → 2.1.397
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +1 -1
- package/src/tools/Chat/README.md +150 -43
- package/src/tools/Chat/components/ChatRoot.tsx +35 -2
- package/src/tools/Chat/components/MessageBubble.tsx +18 -0
- package/src/tools/Chat/hooks/index.ts +4 -0
- package/src/tools/Chat/hooks/useChatUnreadNotifier.ts +134 -0
- package/src/tools/Chat/index.ts +23 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +135 -81
- package/src/tools/Chat/launcher/HeaderSlots.tsx +93 -0
- package/src/tools/Chat/launcher/index.ts +6 -0
- package/src/tools/Chat/launcher/types.ts +132 -0
- package/src/tools/Chat/lazy.tsx +24 -0
- package/src/tools/Chat/notifier/createBrowserNotifier.ts +64 -0
- package/src/tools/Chat/notifier/createCrossTabNotifier.ts +99 -0
- package/src/tools/Chat/notifier/faviconBadge.ts +280 -0
- package/src/tools/Chat/notifier/index.ts +20 -0
- package/src/tools/Chat/notifier/titleRotator.ts +119 -0
- package/src/tools/Chat/notifier/types.ts +38 -0
- package/src/tools/Chat/notifier/visibility.ts +47 -0
- package/src/tools/ImageViewer/components/ImageViewer.tsx +2 -2
- package/src/tools/MarkdownEditor/styles.css +7 -7
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +1 -1
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChatContextValue } from '../context';
|
|
4
|
+
import type { ChatDockPrefs } from '../hooks/useChatDockPrefs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Declarative reset slot. The launcher renders the standard
|
|
8
|
+
* `<ChatHeaderResetButton>` wired to `onReset`, defaulting `onSuccess`
|
|
9
|
+
* to `ctx.clearMessages()`. The button hides itself until a `sessionId`
|
|
10
|
+
* exists on the chat context.
|
|
11
|
+
*/
|
|
12
|
+
export interface ChatHeaderResetSlot {
|
|
13
|
+
/** Backend reset call. Resolves to `true` on success. */
|
|
14
|
+
onReset: () => Promise<boolean>;
|
|
15
|
+
/** Show a confirm dialog before calling `onReset`. @default true */
|
|
16
|
+
confirm?: boolean;
|
|
17
|
+
/** Confirm dialog title. */
|
|
18
|
+
confirmTitle?: string;
|
|
19
|
+
/** Confirm dialog message. */
|
|
20
|
+
confirmMessage?: string;
|
|
21
|
+
/** Override tooltip / aria label. */
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Called after a successful reset. Defaults to `ctx.clearMessages()`.
|
|
25
|
+
* Override to also re-fetch history, navigate, fire analytics, etc.
|
|
26
|
+
*/
|
|
27
|
+
onSuccess?: (ctx: ChatContextValue) => void;
|
|
28
|
+
/** Called on failure (returned `false` or threw). */
|
|
29
|
+
onError?: (err?: unknown) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Declarative language-picker slot. */
|
|
33
|
+
export interface ChatHeaderLanguageSlot {
|
|
34
|
+
/** Subset of BCP-47 tags to offer. */
|
|
35
|
+
allowedTags?: string[];
|
|
36
|
+
/** Override aria-label. */
|
|
37
|
+
ariaLabel?: string;
|
|
38
|
+
/** Hide the globe fallback icon when no flag resolves. */
|
|
39
|
+
hideFallbackIcon?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Declarative mode-toggle slot. The launcher owns `useChatDockPrefs`
|
|
44
|
+
* internally — set `persistAs` to a localStorage key for persistence
|
|
45
|
+
* (omit for ephemeral / session-only mode toggling).
|
|
46
|
+
*/
|
|
47
|
+
export interface ChatHeaderModeToggleSlot {
|
|
48
|
+
/**
|
|
49
|
+
* localStorage key for `useChatDockPrefs`. When provided the toggle
|
|
50
|
+
* persists across reloads; omit to use an ephemeral in-memory toggle.
|
|
51
|
+
*/
|
|
52
|
+
persistAs?: string;
|
|
53
|
+
/** Override the default `{ mode: 'popover', side: 'right', sideWidth: 420 }`. */
|
|
54
|
+
defaults?: Partial<ChatDockPrefs>;
|
|
55
|
+
/** Show the toggle even on viewports below `lg` (1024px). @default true */
|
|
56
|
+
forceVisible?: boolean;
|
|
57
|
+
/** Tooltip / aria for popover → side. */
|
|
58
|
+
expandLabel?: string;
|
|
59
|
+
/** Tooltip / aria for side → popover. */
|
|
60
|
+
collapseLabel?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Header buttons rendered INSIDE the `<ChatProvider>` mounted by
|
|
65
|
+
* `<ChatLauncher>`. Each entry is rendered in the fixed order:
|
|
66
|
+
*
|
|
67
|
+
* custom · languagePicker · modeToggle · audio · reset
|
|
68
|
+
*
|
|
69
|
+
* (close icon is always last, owned by `<ChatHeader>`).
|
|
70
|
+
*
|
|
71
|
+
* Slots that accept `boolean | object`:
|
|
72
|
+
* - `true` → render with default config
|
|
73
|
+
* - `false` → hide explicitly
|
|
74
|
+
* - object → render with the given options
|
|
75
|
+
*
|
|
76
|
+
* Defaults:
|
|
77
|
+
* - `audio`: rendered automatically when `audio` prop is passed to
|
|
78
|
+
* `<ChatLauncher>` AND the resolved instance is not silent.
|
|
79
|
+
* - `modeToggle`, `languagePicker`: off unless opted in.
|
|
80
|
+
* - `reset`: off unless `onReset` is provided.
|
|
81
|
+
*/
|
|
82
|
+
export interface ChatHeaderSlots {
|
|
83
|
+
/** Auto-mute toggle. Defaults to true when launcher `audio` is configured. */
|
|
84
|
+
audio?: boolean;
|
|
85
|
+
/** Popover ↔ side mode toggle. */
|
|
86
|
+
modeToggle?: boolean | ChatHeaderModeToggleSlot;
|
|
87
|
+
/** Speech-recognition language picker. */
|
|
88
|
+
languagePicker?: boolean | ChatHeaderLanguageSlot;
|
|
89
|
+
/** Reset-conversation button. */
|
|
90
|
+
reset?: ChatHeaderResetSlot;
|
|
91
|
+
/** Arbitrary extra buttons rendered first (left-most). */
|
|
92
|
+
custom?: (ctx: ChatContextValue) => ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolved mode-toggle config used by the launcher to pick between
|
|
97
|
+
* the dock's mode/side props and the prefs slot.
|
|
98
|
+
*/
|
|
99
|
+
export interface ResolvedChatHeaderSlots {
|
|
100
|
+
audio: boolean;
|
|
101
|
+
modeToggle: ChatHeaderModeToggleSlot | null;
|
|
102
|
+
languagePicker: ChatHeaderLanguageSlot | null;
|
|
103
|
+
reset: ChatHeaderResetSlot | null;
|
|
104
|
+
custom: ((ctx: ChatContextValue) => ReactNode) | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolveHeaderSlots(
|
|
108
|
+
slots: ChatHeaderSlots | undefined,
|
|
109
|
+
audioConfigured: boolean,
|
|
110
|
+
): ResolvedChatHeaderSlots {
|
|
111
|
+
const s = slots ?? {};
|
|
112
|
+
const audio = s.audio ?? audioConfigured;
|
|
113
|
+
const modeToggle: ChatHeaderModeToggleSlot | null =
|
|
114
|
+
s.modeToggle === true
|
|
115
|
+
? {}
|
|
116
|
+
: s.modeToggle && typeof s.modeToggle === 'object'
|
|
117
|
+
? s.modeToggle
|
|
118
|
+
: null;
|
|
119
|
+
const languagePicker: ChatHeaderLanguageSlot | null =
|
|
120
|
+
s.languagePicker === true
|
|
121
|
+
? {}
|
|
122
|
+
: s.languagePicker && typeof s.languagePicker === 'object'
|
|
123
|
+
? s.languagePicker
|
|
124
|
+
: null;
|
|
125
|
+
return {
|
|
126
|
+
audio,
|
|
127
|
+
modeToggle,
|
|
128
|
+
languagePicker,
|
|
129
|
+
reset: s.reset ?? null,
|
|
130
|
+
custom: s.custom ?? null,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/tools/Chat/lazy.tsx
CHANGED
|
@@ -136,9 +136,11 @@ export {
|
|
|
136
136
|
DEFAULT_DOCK_PREFS,
|
|
137
137
|
useFocusOnEmptyClick,
|
|
138
138
|
useChatUnread,
|
|
139
|
+
useChatUnreadNotifier,
|
|
139
140
|
useChatLightbox,
|
|
140
141
|
type UseChatUnreadOptions,
|
|
141
142
|
type UseChatUnreadReturn,
|
|
143
|
+
type UseChatUnreadNotifierOptions,
|
|
142
144
|
type UseChatConfig,
|
|
143
145
|
type UseChatReturn,
|
|
144
146
|
type UseChatComposerOptions,
|
|
@@ -171,6 +173,24 @@ export {
|
|
|
171
173
|
type UseChatAudioReturn,
|
|
172
174
|
} from './core/audio';
|
|
173
175
|
|
|
176
|
+
// Notifier — title rotation + favicon badge + cross-tab decorator. Pure
|
|
177
|
+
// browser-API code, no React, no UI components.
|
|
178
|
+
export {
|
|
179
|
+
createBrowserNotifier,
|
|
180
|
+
createNoopNotifier,
|
|
181
|
+
createTitleRotator,
|
|
182
|
+
createFaviconBadge,
|
|
183
|
+
createCrossTabNotifier,
|
|
184
|
+
isPageHidden,
|
|
185
|
+
onVisibilityChange,
|
|
186
|
+
type ChatNotifier,
|
|
187
|
+
type BrowserNotifierOptions,
|
|
188
|
+
type TitleRotatorOptions,
|
|
189
|
+
type TitleMode,
|
|
190
|
+
type FaviconBadgeOptions,
|
|
191
|
+
type CrossTabNotifierOptions,
|
|
192
|
+
} from './notifier';
|
|
193
|
+
|
|
174
194
|
// Tool-call payload dispatcher — pure
|
|
175
195
|
export {
|
|
176
196
|
dispatchToolPayload,
|
|
@@ -269,6 +289,10 @@ export type {
|
|
|
269
289
|
ChatLauncherProps,
|
|
270
290
|
ChatLauncherHotkey,
|
|
271
291
|
ChatLauncherGreeting,
|
|
292
|
+
ChatHeaderSlots,
|
|
293
|
+
ChatHeaderResetSlot,
|
|
294
|
+
ChatHeaderLanguageSlot,
|
|
295
|
+
ChatHeaderModeToggleSlot,
|
|
272
296
|
ChatFABProps,
|
|
273
297
|
ChatFABPosition,
|
|
274
298
|
ChatFABVariant,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
import { createFaviconBadge, type FaviconBadgeOptions } from './faviconBadge';
|
|
4
|
+
import { createTitleRotator, type TitleRotatorOptions } from './titleRotator';
|
|
5
|
+
import type { ChatNotifier } from './types';
|
|
6
|
+
|
|
7
|
+
export interface BrowserNotifierOptions {
|
|
8
|
+
/** Title rotation config. Pass `false` to disable title mutation. */
|
|
9
|
+
title?: TitleRotatorOptions | false;
|
|
10
|
+
/** Favicon badge config. Pass `false` to disable favicon mutation. */
|
|
11
|
+
favicon?: FaviconBadgeOptions | false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NOOP: ChatNotifier = { setUnread() {}, clear() {} };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Facebook-style unread notifier: alternates `document.title` between
|
|
18
|
+
* the base title and an alert, plus paints a small badge over the
|
|
19
|
+
* favicon. Both surfaces are optional and individually toggleable.
|
|
20
|
+
*
|
|
21
|
+
* Returns a no-op in SSR or non-DOM environments — safe to construct
|
|
22
|
+
* unconditionally.
|
|
23
|
+
*
|
|
24
|
+
* The notifier itself is **stateless w.r.t. visibility** by design;
|
|
25
|
+
* the hook layer (`useChatUnreadNotifier`) decides when to call
|
|
26
|
+
* `setUnread` vs `clear` based on page focus. Keeping the policy in
|
|
27
|
+
* the hook lets hosts swap in their own notifier (Wails dock badge,
|
|
28
|
+
* cross-tab Zustand broadcaster) without duplicating the gating logic.
|
|
29
|
+
*/
|
|
30
|
+
export function createBrowserNotifier(opts: BrowserNotifierOptions = {}): ChatNotifier {
|
|
31
|
+
if (typeof document === 'undefined') return NOOP;
|
|
32
|
+
|
|
33
|
+
const title = opts.title === false ? null : createTitleRotator(opts.title);
|
|
34
|
+
const favicon = opts.favicon === false ? null : createFaviconBadge(opts.favicon);
|
|
35
|
+
|
|
36
|
+
let active = false;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
setUnread(count: number, latest?: ChatMessage | null) {
|
|
40
|
+
if (count <= 0) {
|
|
41
|
+
this.clear();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (active) {
|
|
45
|
+
title?.update(count, latest);
|
|
46
|
+
favicon?.set(count);
|
|
47
|
+
} else {
|
|
48
|
+
active = true;
|
|
49
|
+
title?.start(count, latest);
|
|
50
|
+
favicon?.set(count);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
clear() {
|
|
54
|
+
if (!active) return;
|
|
55
|
+
active = false;
|
|
56
|
+
title?.stop();
|
|
57
|
+
favicon?.clear();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createNoopNotifier(): ChatNotifier {
|
|
63
|
+
return NOOP;
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
import type { ChatNotifier } from './types';
|
|
4
|
+
|
|
5
|
+
export interface CrossTabNotifierOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The underlying notifier that performs the actual title/favicon
|
|
8
|
+
* mutation (usually `createBrowserNotifier()`). The decorator only
|
|
9
|
+
* forwards calls when this tab is the elected leader; followers stay
|
|
10
|
+
* silent so the title doesn't flicker in stereo across 4 open tabs.
|
|
11
|
+
*/
|
|
12
|
+
inner: ChatNotifier;
|
|
13
|
+
/**
|
|
14
|
+
* Live "this tab is the leader" flag. Read from `useActiveTab` in
|
|
15
|
+
* the hook layer and passed in via the live-getter form so the
|
|
16
|
+
* notifier sees the latest value without React re-rendering it.
|
|
17
|
+
*/
|
|
18
|
+
isLeader: () => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Optional broadcast channel name. Followers subscribe to count
|
|
21
|
+
* updates here so the in-tab badge UI (FAB counter) stays in sync
|
|
22
|
+
* even when they're not the one mutating the title.
|
|
23
|
+
*
|
|
24
|
+
* Default: `djangocfg-chat:unread`.
|
|
25
|
+
*/
|
|
26
|
+
channel?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Callback for follower tabs: fired when a peer (leader) reports an
|
|
29
|
+
* unread count update. Use it to drive a Zustand store that the FAB
|
|
30
|
+
* badge subscribes to.
|
|
31
|
+
*/
|
|
32
|
+
onPeerUpdate?: (count: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BroadcastPayload {
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CHANNEL = 'djangocfg-chat:unread';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decorator over an inner notifier that adds cross-tab coordination.
|
|
43
|
+
*
|
|
44
|
+
* Behaviour:
|
|
45
|
+
* - Leader tab → forwards `setUnread/clear` to the inner notifier
|
|
46
|
+
* AND broadcasts the count so follower tabs can update their FAB
|
|
47
|
+
* badge UI.
|
|
48
|
+
* - Follower tab → does NOT call inner (silent on title/favicon),
|
|
49
|
+
* but still broadcasts via `onPeerUpdate` so the host store
|
|
50
|
+
* reflects the truth.
|
|
51
|
+
*
|
|
52
|
+
* When leadership flips at runtime (e.g. the previous leader closed
|
|
53
|
+
* its tab), the next `setUnread` call from the new leader will start
|
|
54
|
+
* mutating the title — there's no special handoff needed because the
|
|
55
|
+
* inner notifier is idempotent.
|
|
56
|
+
*/
|
|
57
|
+
export function createCrossTabNotifier(opts: CrossTabNotifierOptions): ChatNotifier {
|
|
58
|
+
const { inner, isLeader, onPeerUpdate } = opts;
|
|
59
|
+
const channelName = opts.channel ?? DEFAULT_CHANNEL;
|
|
60
|
+
|
|
61
|
+
let channel: BroadcastChannel | null = null;
|
|
62
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
63
|
+
channel = new BroadcastChannel(channelName);
|
|
64
|
+
if (onPeerUpdate) {
|
|
65
|
+
channel.addEventListener('message', (e) => {
|
|
66
|
+
const data = e.data as BroadcastPayload | undefined;
|
|
67
|
+
if (!data || typeof data.count !== 'number') return;
|
|
68
|
+
onPeerUpdate(data.count);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
setUnread(count: number, latest?: ChatMessage | null) {
|
|
75
|
+
// Broadcast first so followers learn the count even if our local
|
|
76
|
+
// inner notifier no-ops in this environment.
|
|
77
|
+
channel?.postMessage({ count } satisfies BroadcastPayload);
|
|
78
|
+
if (isLeader()) {
|
|
79
|
+
inner.setUnread(count, latest);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
clear() {
|
|
83
|
+
channel?.postMessage({ count: 0 } satisfies BroadcastPayload);
|
|
84
|
+
if (isLeader()) {
|
|
85
|
+
inner.clear();
|
|
86
|
+
} else {
|
|
87
|
+
// Followers never armed the inner notifier, but call clear()
|
|
88
|
+
// anyway in case leadership flipped between an old setUnread
|
|
89
|
+
// and this clear — inner is idempotent.
|
|
90
|
+
inner.clear();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
dispose() {
|
|
94
|
+
channel?.close();
|
|
95
|
+
channel = null;
|
|
96
|
+
inner.dispose?.();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
export interface FaviconBadgeOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Badge fill color. Default Facebook-ish red.
|
|
4
|
+
*/
|
|
5
|
+
badgeColor?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Text/number color. Default white.
|
|
8
|
+
*/
|
|
9
|
+
textColor?: string;
|
|
10
|
+
/**
|
|
11
|
+
* `true` → paint the count inside the badge (caps at 99+).
|
|
12
|
+
* `false` → flat dot. Default `false` — Facebook-style restraint.
|
|
13
|
+
*/
|
|
14
|
+
showCount?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Optional explicit base favicon URL. If omitted, we read the current
|
|
17
|
+
* `<link rel="icon">` href; if there is none, we paint onto a blank
|
|
18
|
+
* canvas so something useful still appears on the tab.
|
|
19
|
+
*/
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Canvas size in CSS px. 32 is the standard favicon hi-DPI size.
|
|
23
|
+
*/
|
|
24
|
+
size?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Pulse the badge by alternating between the badged frame and the
|
|
27
|
+
* bare base favicon. Default `true` — periphery-vision cue that
|
|
28
|
+
* makes the dot findable in a tab bar of dozens of icons. Set
|
|
29
|
+
* `false` for a static dot (quieter; matches enterprise tastes).
|
|
30
|
+
*/
|
|
31
|
+
pulse?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Milliseconds the badge stays visible per cycle. Default 600.
|
|
34
|
+
*/
|
|
35
|
+
pulseOnMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Milliseconds the badge is hidden per cycle. Default 400.
|
|
38
|
+
* Asymmetric on/off (default 600/400) gives a Slack/FB-style
|
|
39
|
+
* heartbeat — the badge is the dominant state, the gap is brief.
|
|
40
|
+
*/
|
|
41
|
+
pulseOffMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BadgeHandle {
|
|
45
|
+
set(count: number): void;
|
|
46
|
+
clear(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LINK_REL_VARIANTS = ['icon', 'shortcut icon'] as const;
|
|
50
|
+
const MANAGED_ATTR = 'data-chat-favicon-badge';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Paints a small badge over the page's favicon using a hidden canvas
|
|
54
|
+
* and swaps the resulting data URL into `<link rel="icon">`. SSR-safe.
|
|
55
|
+
*
|
|
56
|
+
* Notes / gotchas (encoded as invariants):
|
|
57
|
+
* - SVG favicons can't be rastered via `<img>` reliably across
|
|
58
|
+
* browsers without inlining. We attempt anyway; on failure we paint
|
|
59
|
+
* a blank background + the badge so the tab still signals unread.
|
|
60
|
+
* - We never mutate the host's original `<link>`. We add a managed
|
|
61
|
+
* `<link rel="icon" data-chat-favicon-badge>` ahead of it; on clear
|
|
62
|
+
* we remove that managed node. This is the cleanest way to handle
|
|
63
|
+
* hosts that swap their own favicons (e.g. Next.js dynamic icons).
|
|
64
|
+
* - Cross-origin favicons need CORS to draw; if drawing throws we
|
|
65
|
+
* fall back to a CORS-less canvas (badge only, no base image).
|
|
66
|
+
*/
|
|
67
|
+
export function createFaviconBadge(opts: FaviconBadgeOptions = {}): BadgeHandle {
|
|
68
|
+
if (typeof document === 'undefined') {
|
|
69
|
+
return { set() {}, clear() {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const badgeColor = opts.badgeColor ?? '#ef4444';
|
|
73
|
+
const textColor = opts.textColor ?? '#ffffff';
|
|
74
|
+
const showCount = opts.showCount ?? false;
|
|
75
|
+
const size = opts.size ?? 32;
|
|
76
|
+
const pulse = opts.pulse ?? true;
|
|
77
|
+
const pulseOnMs = opts.pulseOnMs ?? 600;
|
|
78
|
+
const pulseOffMs = opts.pulseOffMs ?? 400;
|
|
79
|
+
|
|
80
|
+
let baseImg: HTMLImageElement | null = null;
|
|
81
|
+
let baseLoaded = false;
|
|
82
|
+
let lastCount = -1;
|
|
83
|
+
|
|
84
|
+
// Pulse animation state.
|
|
85
|
+
let frameOn: string | null = null; // badged frame data URL
|
|
86
|
+
let frameOff: string | null = null; // bare base frame data URL (or empty)
|
|
87
|
+
let pulseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
88
|
+
let pulsePhase: 'on' | 'off' = 'on';
|
|
89
|
+
|
|
90
|
+
const detectBaseUrl = (): string | null => {
|
|
91
|
+
if (opts.baseUrl) return opts.baseUrl;
|
|
92
|
+
for (const rel of LINK_REL_VARIANTS) {
|
|
93
|
+
const link = document.querySelector<HTMLLinkElement>(
|
|
94
|
+
`link[rel="${rel}"]:not([${MANAGED_ATTR}])`,
|
|
95
|
+
);
|
|
96
|
+
if (link?.href) return link.href;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const loadBase = () => {
|
|
102
|
+
if (baseImg) return;
|
|
103
|
+
const url = detectBaseUrl();
|
|
104
|
+
if (!url) return;
|
|
105
|
+
const img = new Image();
|
|
106
|
+
img.crossOrigin = 'anonymous';
|
|
107
|
+
img.onload = () => {
|
|
108
|
+
baseLoaded = true;
|
|
109
|
+
// If a `set()` came in before load completed, re-render now.
|
|
110
|
+
if (lastCount >= 0) renderFrames(lastCount);
|
|
111
|
+
};
|
|
112
|
+
img.onerror = () => {
|
|
113
|
+
// Network/CORS failure: leave baseLoaded=false; we'll paint badge
|
|
114
|
+
// on a blank canvas.
|
|
115
|
+
baseLoaded = false;
|
|
116
|
+
};
|
|
117
|
+
img.src = url;
|
|
118
|
+
baseImg = img;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const removeManagedLinks = () => {
|
|
122
|
+
document
|
|
123
|
+
.querySelectorAll(`link[${MANAGED_ATTR}]`)
|
|
124
|
+
.forEach((node) => node.remove());
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const applyDataUrl = (dataUrl: string) => {
|
|
128
|
+
removeManagedLinks();
|
|
129
|
+
const link = document.createElement('link');
|
|
130
|
+
link.rel = 'icon';
|
|
131
|
+
link.type = 'image/png';
|
|
132
|
+
link.href = dataUrl;
|
|
133
|
+
link.setAttribute(MANAGED_ATTR, 'true');
|
|
134
|
+
document.head.appendChild(link);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Paint the off-phase frame. If we have a usable base favicon, this
|
|
138
|
+
// is the bare base; otherwise a transparent canvas. Returning a
|
|
139
|
+
// transparent frame (rather than `null` + removing the link) is
|
|
140
|
+
// load-bearing: Chrome only re-rasters the tab favicon when the
|
|
141
|
+
// managed `<link>`'s href *changes*, so off-phase MUST swap to a
|
|
142
|
+
// different data URL — not vanish — for the pulse to be visible.
|
|
143
|
+
const renderOffFrame = (): string | null => {
|
|
144
|
+
const canvas = document.createElement('canvas');
|
|
145
|
+
canvas.width = size;
|
|
146
|
+
canvas.height = size;
|
|
147
|
+
const ctx = canvas.getContext('2d');
|
|
148
|
+
if (!ctx) return null;
|
|
149
|
+
|
|
150
|
+
if (baseImg && baseLoaded) {
|
|
151
|
+
try {
|
|
152
|
+
ctx.drawImage(baseImg, 0, 0, size, size);
|
|
153
|
+
} catch {
|
|
154
|
+
// Tainted canvas → fall through to the empty frame.
|
|
155
|
+
ctx.clearRect(0, 0, size, size);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// If no base, the canvas stays fully transparent — Chrome falls
|
|
159
|
+
// back to its default tab glyph, which still gives a visible
|
|
160
|
+
// on/off contrast against the red badge.
|
|
161
|
+
try {
|
|
162
|
+
return canvas.toDataURL('image/png');
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Paint the badged frame. Reused for both the static (no-pulse) case
|
|
169
|
+
// and the `on` phase of the pulse loop.
|
|
170
|
+
const renderBadged = (count: number): string | null => {
|
|
171
|
+
const canvas = document.createElement('canvas');
|
|
172
|
+
canvas.width = size;
|
|
173
|
+
canvas.height = size;
|
|
174
|
+
const ctx = canvas.getContext('2d');
|
|
175
|
+
if (!ctx) return null;
|
|
176
|
+
|
|
177
|
+
if (baseImg && baseLoaded) {
|
|
178
|
+
try {
|
|
179
|
+
ctx.drawImage(baseImg, 0, 0, size, size);
|
|
180
|
+
} catch {
|
|
181
|
+
ctx.clearRect(0, 0, size, size);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const r = size * 0.28;
|
|
186
|
+
const cx = size - r - size * 0.04;
|
|
187
|
+
const cy = size - r - size * 0.04;
|
|
188
|
+
|
|
189
|
+
ctx.fillStyle = badgeColor;
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
192
|
+
ctx.fill();
|
|
193
|
+
|
|
194
|
+
if (showCount) {
|
|
195
|
+
const label = count > 99 ? '99+' : String(count);
|
|
196
|
+
ctx.fillStyle = textColor;
|
|
197
|
+
const fontSize = label.length >= 3 ? r * 0.85 : r * 1.25;
|
|
198
|
+
ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
|
|
199
|
+
ctx.textAlign = 'center';
|
|
200
|
+
ctx.textBaseline = 'middle';
|
|
201
|
+
ctx.fillText(label, cx, cy + fontSize * 0.06);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return canvas.toDataURL('image/png');
|
|
206
|
+
} catch {
|
|
207
|
+
// Tainted-canvas fallback: badge only, no base.
|
|
208
|
+
const fb = document.createElement('canvas');
|
|
209
|
+
fb.width = size;
|
|
210
|
+
fb.height = size;
|
|
211
|
+
const fctx = fb.getContext('2d');
|
|
212
|
+
if (!fctx) return null;
|
|
213
|
+
fctx.fillStyle = badgeColor;
|
|
214
|
+
fctx.beginPath();
|
|
215
|
+
fctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
216
|
+
fctx.fill();
|
|
217
|
+
return fb.toDataURL('image/png');
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const stopPulse = () => {
|
|
222
|
+
if (pulseTimer !== null) {
|
|
223
|
+
clearTimeout(pulseTimer);
|
|
224
|
+
pulseTimer = null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const tickPulse = () => {
|
|
229
|
+
if (frameOn === null) return;
|
|
230
|
+
// Always swap the managed <link>'s href between two distinct data
|
|
231
|
+
// URLs — Chrome / Safari skip the redraw if the href is identical
|
|
232
|
+
// to what was just set, so removing-and-readding the node isn't
|
|
233
|
+
// enough on its own (`renderOffFrame` guarantees a non-null off
|
|
234
|
+
// frame whenever frameOn exists).
|
|
235
|
+
if (pulsePhase === 'on') {
|
|
236
|
+
applyDataUrl(frameOn);
|
|
237
|
+
pulsePhase = 'off';
|
|
238
|
+
pulseTimer = setTimeout(tickPulse, pulseOnMs);
|
|
239
|
+
} else {
|
|
240
|
+
applyDataUrl(frameOff ?? frameOn);
|
|
241
|
+
pulsePhase = 'on';
|
|
242
|
+
pulseTimer = setTimeout(tickPulse, pulseOffMs);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const renderFrames = (count: number) => {
|
|
247
|
+
frameOn = renderBadged(count);
|
|
248
|
+
frameOff = renderOffFrame();
|
|
249
|
+
|
|
250
|
+
if (frameOn === null) return;
|
|
251
|
+
|
|
252
|
+
if (!pulse) {
|
|
253
|
+
applyDataUrl(frameOn);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// (Re-)start the pulse loop. If a previous loop was running we
|
|
258
|
+
// tear it down first so the cadence doesn't double up.
|
|
259
|
+
stopPulse();
|
|
260
|
+
pulsePhase = 'on';
|
|
261
|
+
tickPulse();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
set(count) {
|
|
266
|
+
lastCount = count;
|
|
267
|
+
loadBase();
|
|
268
|
+
// If base not loaded yet, the onload handler will call
|
|
269
|
+
// renderFrames(lastCount) once it resolves.
|
|
270
|
+
renderFrames(count);
|
|
271
|
+
},
|
|
272
|
+
clear() {
|
|
273
|
+
lastCount = -1;
|
|
274
|
+
stopPulse();
|
|
275
|
+
frameOn = null;
|
|
276
|
+
frameOff = null;
|
|
277
|
+
removeManagedLinks();
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type { ChatNotifier } from './types';
|
|
2
|
+
export {
|
|
3
|
+
createBrowserNotifier,
|
|
4
|
+
createNoopNotifier,
|
|
5
|
+
type BrowserNotifierOptions,
|
|
6
|
+
} from './createBrowserNotifier';
|
|
7
|
+
export {
|
|
8
|
+
createTitleRotator,
|
|
9
|
+
type TitleRotatorOptions,
|
|
10
|
+
type TitleMode,
|
|
11
|
+
} from './titleRotator';
|
|
12
|
+
export {
|
|
13
|
+
createFaviconBadge,
|
|
14
|
+
type FaviconBadgeOptions,
|
|
15
|
+
} from './faviconBadge';
|
|
16
|
+
export { isPageHidden, onVisibilityChange } from './visibility';
|
|
17
|
+
export {
|
|
18
|
+
createCrossTabNotifier,
|
|
19
|
+
type CrossTabNotifierOptions,
|
|
20
|
+
} from './createCrossTabNotifier';
|