@djangocfg/ui-tools 2.1.393 → 2.1.395
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 +130 -42
- package/src/tools/Chat/components/ChatRoot.tsx +26 -2
- 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/PrettyCode/lazy.tsx +6 -0
|
@@ -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';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
export type TitleMode = 'rotate' | 'prefix';
|
|
4
|
+
|
|
5
|
+
export interface TitleRotatorOptions {
|
|
6
|
+
/** Snapshot taken on first `start()` if omitted. */
|
|
7
|
+
base?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Build the alert string for the current unread state.
|
|
10
|
+
* Only used in `rotate` mode. Default: `"(N) Base"` — same shape as
|
|
11
|
+
* the prefix mode, so the two modes differ only in *whether* the
|
|
12
|
+
* title rotates, not in what it says.
|
|
13
|
+
*/
|
|
14
|
+
template?: (count: number, latest?: ChatMessage | null, base?: string) => string;
|
|
15
|
+
/** Only used in `rotate` mode. Default 2000ms — gentle, not nervous. */
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* - `prefix` (default): render `"(N) Base"` once, no interval. The
|
|
19
|
+
* restrained Facebook-style cue: rely on the favicon dot for
|
|
20
|
+
* attention, keep the title readable.
|
|
21
|
+
* - `rotate`: swap between base and `template(...)` every
|
|
22
|
+
* `intervalMs`. Use when the host has no favicon (or the user
|
|
23
|
+
* pinned the tab and favicons are hidden).
|
|
24
|
+
*/
|
|
25
|
+
mode?: TitleMode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RotatorHandle {
|
|
29
|
+
start(count: number, latest?: ChatMessage | null): void;
|
|
30
|
+
update(count: number, latest?: ChatMessage | null): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TEMPLATE = (count: number, _latest?: ChatMessage | null, base?: string) =>
|
|
35
|
+
base ? `(${count}) ${base}` : `(${count})`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mutates `document.title`. SSR-safe (returns no-op handle).
|
|
39
|
+
*
|
|
40
|
+
* Invariants:
|
|
41
|
+
* - `stop()` always restores the base title.
|
|
42
|
+
* - Multiple `start()` calls without an intervening `stop()` are
|
|
43
|
+
* equivalent to `update()`; we never stack timers.
|
|
44
|
+
* - In `rotate` mode the timer only ticks when the page is hidden
|
|
45
|
+
* (the caller is expected to gate this; see `useChatUnreadNotifier`).
|
|
46
|
+
*/
|
|
47
|
+
export function createTitleRotator(opts: TitleRotatorOptions = {}): RotatorHandle {
|
|
48
|
+
if (typeof document === 'undefined') {
|
|
49
|
+
return { start() {}, update() {}, stop() {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const template = opts.template ?? DEFAULT_TEMPLATE;
|
|
53
|
+
const intervalMs = opts.intervalMs ?? 2000;
|
|
54
|
+
const mode: TitleMode = opts.mode ?? 'prefix';
|
|
55
|
+
|
|
56
|
+
let baseTitle: string | null = null;
|
|
57
|
+
let alertTitle = '';
|
|
58
|
+
let phase: 'base' | 'alert' = 'base';
|
|
59
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
|
|
61
|
+
const captureBaseOnce = () => {
|
|
62
|
+
if (baseTitle !== null) return;
|
|
63
|
+
baseTitle = opts.base ?? document.title;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const render = () => {
|
|
67
|
+
if (baseTitle === null) return;
|
|
68
|
+
document.title = phase === 'alert' ? alertTitle : baseTitle;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const tick = () => {
|
|
72
|
+
phase = phase === 'alert' ? 'base' : 'alert';
|
|
73
|
+
render();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const prefixTitle = (count: number) =>
|
|
77
|
+
baseTitle ? `(${count}) ${baseTitle}` : `(${count})`;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
start(count, latest) {
|
|
81
|
+
captureBaseOnce();
|
|
82
|
+
alertTitle = template(count, latest, baseTitle ?? undefined);
|
|
83
|
+
|
|
84
|
+
if (mode === 'prefix') {
|
|
85
|
+
document.title = prefixTitle(count);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
phase = 'alert';
|
|
90
|
+
render();
|
|
91
|
+
if (timer === null) {
|
|
92
|
+
timer = setInterval(tick, intervalMs);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
update(count, latest) {
|
|
97
|
+
// Same path as start — base title is already captured, just refresh
|
|
98
|
+
// the alert string and re-render in case we're in alert phase.
|
|
99
|
+
captureBaseOnce();
|
|
100
|
+
alertTitle = template(count, latest, baseTitle ?? undefined);
|
|
101
|
+
if (mode === 'prefix') {
|
|
102
|
+
document.title = prefixTitle(count);
|
|
103
|
+
} else if (phase === 'alert') {
|
|
104
|
+
render();
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
if (timer !== null) {
|
|
110
|
+
clearInterval(timer);
|
|
111
|
+
timer = null;
|
|
112
|
+
}
|
|
113
|
+
if (baseTitle !== null) {
|
|
114
|
+
document.title = baseTitle;
|
|
115
|
+
}
|
|
116
|
+
phase = 'base';
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transport-agnostic surface for surfacing "you have new messages" to the
|
|
5
|
+
* user when they're not actively watching the chat.
|
|
6
|
+
*
|
|
7
|
+
* Two reference implementations ship with ui-tools:
|
|
8
|
+
* - `createBrowserNotifier` — Facebook-style document.title rotation +
|
|
9
|
+
* favicon badge, suitable for browser tabs.
|
|
10
|
+
* - `createNoopNotifier` — does nothing; default when no DOM is around
|
|
11
|
+
* and the safe pick inside Wails / Electron, where the host wires
|
|
12
|
+
* its own native dock-badge bridge instead.
|
|
13
|
+
*
|
|
14
|
+
* Hosts that want native badges (Wails dock, macOS notification dot)
|
|
15
|
+
* implement this interface themselves and pass the instance into
|
|
16
|
+
* `useChatUnreadNotifier({ notifier })`.
|
|
17
|
+
*/
|
|
18
|
+
export interface ChatNotifier {
|
|
19
|
+
/**
|
|
20
|
+
* Called whenever the unread count is non-zero AND the page is not
|
|
21
|
+
* visible to the user. Implementations must be idempotent — they may
|
|
22
|
+
* be called repeatedly with the same count.
|
|
23
|
+
*/
|
|
24
|
+
setUnread(count: number, latest?: ChatMessage | null): void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Called when the user is back (page visible, or dock opened) OR when
|
|
28
|
+
* unread drops to zero. Implementations must fully restore any state
|
|
29
|
+
* they mutated (title, favicon).
|
|
30
|
+
*/
|
|
31
|
+
clear(): void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional: release any resources (DOM listeners, cached canvases).
|
|
35
|
+
* Called on hook unmount. Safe to omit.
|
|
36
|
+
*/
|
|
37
|
+
dispose?(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-visibility helpers. SSR-safe — every entry point guards on
|
|
3
|
+
* `typeof document`.
|
|
4
|
+
*
|
|
5
|
+
* Why `hidden || !hasFocus` instead of just `hidden`:
|
|
6
|
+
* - `document.hidden` flips true only when the tab is fully
|
|
7
|
+
* backgrounded (other tab, minimised window).
|
|
8
|
+
* - A focused window in the foreground with the chat tab visible but
|
|
9
|
+
* the user typing into a different app still counts as "watching"
|
|
10
|
+
* for Chrome's purposes. We treat that as away too, so unread
|
|
11
|
+
* notifications kick in when you alt-tab to your editor.
|
|
12
|
+
*
|
|
13
|
+
* Inside Wails / Electron with a single window, `document.hidden` is
|
|
14
|
+
* always false and `document.hasFocus()` tracks the OS window — which
|
|
15
|
+
* is exactly what we want for a desktop dock badge if a host ever
|
|
16
|
+
* decides to reuse the browser notifier (most won't; they'll ship
|
|
17
|
+
* their own ChatNotifier).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function isPageHidden(): boolean {
|
|
21
|
+
if (typeof document === 'undefined') return false;
|
|
22
|
+
if (document.hidden) return true;
|
|
23
|
+
if (typeof document.hasFocus === 'function' && !document.hasFocus()) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribe to visibility/focus changes. Returns an unsubscribe fn.
|
|
29
|
+
* No-op in SSR.
|
|
30
|
+
*/
|
|
31
|
+
export function onVisibilityChange(cb: (hidden: boolean) => void): () => void {
|
|
32
|
+
if (typeof document === 'undefined') return () => {};
|
|
33
|
+
|
|
34
|
+
const fire = () => cb(isPageHidden());
|
|
35
|
+
document.addEventListener('visibilitychange', fire);
|
|
36
|
+
// `focus` / `blur` on window catch alt-tab-without-visibility-change
|
|
37
|
+
// (Chrome on macOS notably keeps `visibilityState === 'visible'` for
|
|
38
|
+
// background-but-on-screen tabs).
|
|
39
|
+
window.addEventListener('focus', fire);
|
|
40
|
+
window.addEventListener('blur', fire);
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener('visibilitychange', fire);
|
|
44
|
+
window.removeEventListener('focus', fire);
|
|
45
|
+
window.removeEventListener('blur', fire);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -156,8 +156,8 @@ export function ImageViewer({
|
|
|
156
156
|
className={cn(
|
|
157
157
|
'flex-1 h-full relative overflow-hidden outline-none',
|
|
158
158
|
'bg-[length:16px_16px]',
|
|
159
|
-
'[background-color:
|
|
160
|
-
'[background-image:linear-gradient(45deg,
|
|
159
|
+
'[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
|
|
160
|
+
'[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
|
|
161
161
|
'[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
|
|
162
162
|
)}
|
|
163
163
|
>
|
|
@@ -88,13 +88,13 @@
|
|
|
88
88
|
|
|
89
89
|
/* Focus ring */
|
|
90
90
|
.markdown-editor:focus-within {
|
|
91
|
-
border-color: var(--color-ring,
|
|
92
|
-
box-shadow: 0 0 0 2px var(--color-ring,
|
|
91
|
+
border-color: var(--color-ring, var(--ring));
|
|
92
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-ring, var(--ring)) 30%, transparent);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/* Mention inline chip */
|
|
96
96
|
.markdown-mention {
|
|
97
|
-
background: var(--color-primary,
|
|
97
|
+
background: var(--color-primary, var(--primary));
|
|
98
98
|
color: var(--color-primary-foreground, #fff);
|
|
99
99
|
padding: 1px 6px;
|
|
100
100
|
border-radius: 4px;
|
|
@@ -105,9 +105,9 @@
|
|
|
105
105
|
|
|
106
106
|
/* Mention dropdown */
|
|
107
107
|
.markdown-mention-list {
|
|
108
|
-
background: var(--color-popover,
|
|
109
|
-
color: var(--color-popover-foreground,
|
|
110
|
-
border: 1px solid var(--color-border,
|
|
108
|
+
background: var(--color-popover, var(--popover));
|
|
109
|
+
color: var(--color-popover-foreground, var(--popover-foreground));
|
|
110
|
+
border: 1px solid var(--color-border, var(--border));
|
|
111
111
|
border-radius: 8px;
|
|
112
112
|
padding: 4px;
|
|
113
113
|
min-width: 220px;
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
|
|
134
134
|
.markdown-mention-item:hover,
|
|
135
135
|
.markdown-mention-item.selected {
|
|
136
|
-
background: var(--color-muted,
|
|
136
|
+
background: var(--color-muted, var(--muted));
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
.markdown-mention-avatar {
|
|
@@ -67,3 +67,9 @@ export const LazyPrettyCode = createLazyComponent<PrettyCodeProps>(
|
|
|
67
67
|
fallback: <CodeLoadingFallback />,
|
|
68
68
|
}
|
|
69
69
|
);
|
|
70
|
+
|
|
71
|
+
// `PrettyCode` is the historical named export — same component as
|
|
72
|
+
// `LazyPrettyCode`, kept for backwards compatibility with callers that
|
|
73
|
+
// imported it from the old root barrel.
|
|
74
|
+
export { LazyPrettyCode as PrettyCode };
|
|
75
|
+
export { default } from './index';
|