feat(news): interaction seam for What's New (impression/open/link_click/video_play)

New lib/newsEvents.ts registry (same pattern as newsSource.ts): OSS
default is a no-op sink; an extension overlay can register one to
forward the signals to its own analytics. NewsAnnouncer reports the
toast impression and modal opens (toast vs Help menu); NewsMarkdown
grows an optional onInteract callback fired on body link clicks and
YouTube preview plays — the admin authoring preview passes nothing, so
authoring never counts as engagement.
This commit is contained in:
David Montero 2026-08-11 18:37:23 +02:00
parent 8f7b878149
commit 6e64027921
3 changed files with 110 additions and 7 deletions

View File

@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
import { reportNewsEvent } from '../../lib/newsEvents';
import { NewsMarkdown } from './NewsMarkdown';
import { registerEditorCommand } from '../../lib/editorCommands';
import './NewsAnnouncer.css';
@ -46,6 +47,7 @@ export function NewsAnnouncer() {
if (cancelled || !p) return;
setPost(p);
setToastVisible(true);
reportNewsEvent('impression', p.id);
});
}, FETCH_DELAY_MS);
return () => {
@ -62,6 +64,7 @@ export function NewsAnnouncer() {
return registerEditorCommand('help.whatsNew', () => {
setToastVisible(false);
setExpanded(true);
reportNewsEvent('open', post.id, { via: 'menu' });
});
}, [post]);
@ -101,7 +104,17 @@ export function NewsAnnouncer() {
</button>
</div>
<div className="velxio-news-body">
<NewsMarkdown>{post.body_md}</NewsMarkdown>
<NewsMarkdown
onInteract={(kind, href) =>
reportNewsEvent(
kind === 'video' ? 'video_play' : 'link_click',
post.id,
{ href },
)
}
>
{post.body_md}
</NewsMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setExpanded(false)}>
@ -136,6 +149,7 @@ export function NewsAnnouncer() {
className="velxio-news-toast-more"
onClick={() => {
setExpanded(true);
reportNewsEvent('open', post.id, { via: 'toast' });
}}
>
{t('news.readMore', 'Read more')} →

View File

@ -17,6 +17,11 @@
* knowing the ![](…) syntax. An explicit [label](…png) stays a link.
* - All links open in a new tab (the modal sits on top of the editor;
* navigating away would lose workspace state).
*
* The optional `onInteract` callback fires when the reader clicks a link
* ('link') or starts a video preview ('video'). The announcement passes
* a handler wired to lib/newsEvents.ts; the admin preview passes nothing,
* so authoring never counts as engagement.
*/
import { useState } from 'react';
@ -37,7 +42,9 @@ function youTubeId(href: string): string | null {
return m ? m[1] : null;
}
function YouTubePreview({ id }: { id: string }) {
export type NewsInteractKind = 'link' | 'video';
function YouTubePreview({ id, onPlay }: { id: string; onPlay?: () => void }) {
const [playing, setPlaying] = useState(false);
if (playing) {
return (
@ -55,7 +62,10 @@ function YouTubePreview({ id }: { id: string }) {
<button
type="button"
className="velxio-news-video velxio-news-video-thumb"
onClick={() => setPlaying(true)}
onClick={() => {
setPlaying(true);
onPlay?.();
}}
aria-label="Play video"
>
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} alt="Video preview" loading="lazy" />
@ -64,7 +74,13 @@ function YouTubePreview({ id }: { id: string }) {
);
}
export function NewsMarkdown({ children }: { children: string }) {
export function NewsMarkdown({
children,
onInteract,
}: {
children: string;
onInteract?: (kind: NewsInteractKind, href: string) => void;
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@ -75,9 +91,19 @@ export function NewsMarkdown({ children }: { children: string }) {
if (id) {
return (
<>
<YouTubePreview id={id} />
<YouTubePreview
id={id}
onPlay={href ? () => onInteract?.('video', href) : undefined}
/>
{text && text !== href && (
<a href={href} target="_blank" rel="noreferrer">{kids}</a>
<a
href={href}
target="_blank"
rel="noreferrer"
onClick={() => href && onInteract?.('link', href)}
>
{kids}
</a>
)}
</>
);
@ -86,7 +112,14 @@ export function NewsMarkdown({ children }: { children: string }) {
return <img src={href} alt="" loading="lazy" />;
}
return (
<a href={href} target="_blank" rel="noreferrer">{kids}</a>
<a
href={href}
target="_blank"
rel="noreferrer"
onClick={() => href && onInteract?.('link', href)}
>
{kids}
</a>
);
},
img: ({ src, alt }) => (

View File

@ -0,0 +1,56 @@
/**
* Product-news interaction sink ("What's New" telemetry seam).
*
* Same seam pattern as newsSource.ts / proSession.ts: OSS ships a no-op
* default and an extension overlay may register a sink that forwards the
* signals wherever it wants (velxio.dev sends them to its analytics
* pipeline). A pure OSS build never reports anything anywhere — the
* events below simply vanish.
*
* Event vocabulary (postId is always the news post's id):
* - impression → the corner toast rendered for the user
* - open → the full post modal was opened (detail.via:
* 'toast' | 'menu')
* - link_click → a link inside the post body was clicked
* (detail.href)
* - video_play → a YouTube preview inside the body was started
* (detail.href)
*/
export type NewsEventKind = 'impression' | 'open' | 'link_click' | 'video_play';
export interface NewsEventDetail {
/** Target URL for link_click / video_play. */
href?: string;
/** How the modal was opened for 'open'. */
via?: 'toast' | 'menu';
}
type NewsEventSink = (
kind: NewsEventKind,
postId: string,
detail?: NewsEventDetail,
) => void;
let _sink: NewsEventSink | null = null;
export function registerNewsEventSink(sink: NewsEventSink): void {
_sink = sink;
}
/**
* Report a news interaction. Never throws — telemetry must not be able
* to break the announcement UI.
*/
export function reportNewsEvent(
kind: NewsEventKind,
postId: string,
detail?: NewsEventDetail,
): void {
try {
_sink?.(kind, postId, detail);
} catch (err) {
// eslint-disable-next-line no-console
console.debug('[oss] news event sink failed:', err);
}
}