import React from 'react';
import { AiFillStar } from 'react-icons/ai';
import { SiGithub } from 'react-icons/si';
import { useIntl } from 'react-intl';
import { REPOSITORY_URL } from '@/constants/site';
import { clx } from '@/helpers';
import styles from './starPrompt.module.css';
export const STAR_PROMPT_STORAGE_KEY = 'star-prompt';
/** How far down the surface counts as having read it. */
const READ_FRACTION = 0.6;
/** And how long, so that a flick of the wheel to the bottom does not qualify. */
const DWELL_MS = 30_000;
/**
* Below this, a scroller is a list or a sidebar rather than something that was read. Measured: the
* blog body is 2409px and the privacy policy 4692, against an 800px window.
*/
const MIN_SCROLLABLE_PX = 400;
/**
* A macOS notification asking for a star, shown once per browser and never on arrival.
*
* The menu bar extra beside the clock is the permanent, passive half of this: it is there for
* anyone who looks. This is the other half — it asks, once, and only of someone who has actually
* read something. Both conditions have to hold: 60% of the surface AND 30 seconds on it, because
* either one alone is satisfied by a wheel flick or by an abandoned tab.
*
* **The window never scrolls here.** This is a desktop simulation: `document.documentElement` is
* exactly the viewport on every route, and the reading happens inside a panel — the blog body and
* the legal article are the two that scroll, at 2409px and 4692px against an 800px window. A
* listener on `window` would therefore never have fired once, which is how this was written first
* and what measuring it caught. So the listener is on `document` in the CAPTURE phase, because a
* scroll event on an element does not bubble, and the fraction is read off whatever scrolled.
*
* The key is written the moment it appears, not when it is answered. Ignoring a request is an
* answer, and a prompt that returns until it gets a click is the pattern this is trying not to be.
*/
const StarPrompt = () => {
const { formatMessage: f } = useIntl();
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
let alreadyAsked = true;
try {
alreadyAsked = window.localStorage.getItem(STAR_PROMPT_STORAGE_KEY) !== null;
} catch {
/* Private mode, or storage denied. Saying nothing is the safe direction. */
}
if (alreadyAsked) return undefined;
const openedAt = Date.now();
const onScroll = (event: Event) => {
const target = event.target;
const scroller = target instanceof Element ? target : document.documentElement;
const scrollable = scroller.scrollHeight - scroller.clientHeight;
if (scrollable < MIN_SCROLLABLE_PX) return;
const read = (scroller.scrollTop + scroller.clientHeight) / scroller.scrollHeight;
if (read < READ_FRACTION || Date.now() - openedAt < DWELL_MS) return;
document.removeEventListener('scroll', onScroll, true);
try {
window.localStorage.setItem(STAR_PROMPT_STORAGE_KEY, 'seen');
} catch {
/* See above: if it cannot be recorded it will simply be asked again. */
}
setVisible(true);
};
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
return () => document.removeEventListener('scroll', onScroll, true);
}, []);
const answer = React.useCallback((choice: 'starred' | 'dismissed') => {
try {
window.localStorage.setItem(STAR_PROMPT_STORAGE_KEY, choice);
} catch {
/* The prompt is closing either way. */
}
setVisible(false);
}, []);
React.useEffect(() => {
if (!visible) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') answer('dismissed');
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [visible, answer]);
if (!visible) return null;
return (
<div data-testid="star-prompt" role="status" className={clx(styles.banner, styles.enter)}>
<span className={styles.icon} aria-hidden="true">
<SiGithub />
</span>
<div className={styles.body}>
<p className={styles.title}>{f({ id: 'starPrompt.title' })}</p>
<p className={styles.message}>{f({ id: 'starPrompt.message' })}</p>
<div className={styles.actions}>
<a
className={styles.accept}
href={REPOSITORY_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => answer('starred')}
>
<AiFillStar aria-hidden="true" />
{f({ id: 'starPrompt.accept' })}
</a>
<button type="button" className={styles.dismiss} onClick={() => answer('dismissed')}>
{f({ id: 'starPrompt.dismiss' })}
</button>
</div>
</div>
</div>
);
};
export default StarPrompt;