Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 18x 18x 18x 10x 10x 10x 10x 9x 9x 7x 7x 7x 7x 6x 6x 4x 4x 4x 4x 9x 9x 18x 2x 2x 2x 18x 16x 4x 1x 4x 4x 18x 1x 12x | 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;
|