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:
parent
8f7b878149
commit
6e64027921
|
|
@ -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')} →
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue