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 125 126 127 128 129 130 131 132 133 134 135 136 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 12x 12x 1x 1x 1x 18x 18x 12x 12x 12x 12x 12x 10x 10x 12x 9x 12x 6x 6x 12x 12x 2x 2x 4x 12x 12x 12x 12x 12x 12x 18x 18x 1x 1x 1x 18x 18x 1x 1x 18x 18x 18x | import { reviewPrompt } from '@/../config.json';
import { CHROME_WEBSTORE_REVIEWS_URL } from '@/constants/common';
import { getInstallSource } from '@/helpers/updates';
import {
ensureInstallDate,
getReviewPromptState,
markReviewed,
snoozeReviewPrompt,
} from '@/utils/reviewStats';
import { useCallback, useEffect, useState } from 'react';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
const logger = new Logger('useReviewPrompt');
/** Milliseconds in a day, for the install-age gate. */
const DAY_MS = 24 * 60 * 60 * 1000;
// Eligibility thresholds live in `config.json` under `reviewPrompt` so they can be
// tuned without a code change: days installed and searches run before the prompt
// appears, and the dismissal count that silences it for good.
const { minDaysInstalled: MIN_DAYS_INSTALLED, minSearches: MIN_SEARCHES } = reviewPrompt;
const MAX_DISMISSALS = reviewPrompt.maxDismissals;
/**
* The usage milestone worth asking the user to rate the extension over.
* @category Hooks
* @group Types
*/
export interface ReviewNotice {
/** Whole days since the extension was installed (or first seen). */
days: number;
/** Lifetime number of searches the user has run. */
searches: number;
/** Lifetime number of products those searches returned. */
products: number;
}
/**
* Return shape of {@link useReviewPrompt}.
*/
interface UseReviewPrompt {
/** The milestone to celebrate, or `undefined` when the prompt shouldn't show. */
notice: ReviewNotice | undefined;
/** Opens the Web Store reviews page and silences the prompt permanently. */
onReview: () => void;
/** Snoozes (first time) or silences (thereafter) the prompt. */
onDismiss: () => void;
}
/**
* Detects a Firefox runtime by its extension URL scheme. The reviews link is
* Chrome-specific, so the prompt stays hidden on Firefox builds.
* @returns True when running as a `moz-extension://` add-on.
* @source
*/
function isFirefoxRuntime(): boolean {
try {
return chrome.runtime.getURL('').startsWith('moz-extension://');
} catch {
return false;
}
}
/**
* Nudges engaged users to leave a Chrome Web Store review once they've clearly
* gotten value from the extension.
*
* The prompt appears only for a Web Store install that has been present at least
* `reviewPrompt.minDaysInstalled` days and run at least `reviewPrompt.minSearches`
* searches (both from `config.json`).
* The first dismissal snoozes it ~30 days for one final showing; a second
* dismissal — or opening the reviews page — silences it for good. Install date
* and counters come from {@link getReviewPromptState}; a pre-existing user with
* no recorded date is backfilled with "now" on this first check.
* @returns The pending {@link ReviewNotice} plus `onReview` / `onDismiss` actions.
* @category Hooks
* @example
* ```tsx
* const { notice, onReview, onDismiss } = useReviewPrompt();
* // after 15 days and 6 searches returning 42 products:
* // notice → { days: 15, searches: 6, products: 42 }
* ```
* @source
*/
export function useReviewPrompt(): UseReviewPrompt {
const [notice, setNotice] = useState<ReviewNotice | undefined>(undefined);
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
// Only Web Store (Chrome) installs can leave a store review.
if (isFirefoxRuntime() || getInstallSource() !== 'webstore') return;
await ensureInstallDate();
const state = await getReviewPromptState();
if (cancelled || state.reviewed || state.installedAt <= 0) return;
const days = Math.floor((Date.now() - state.installedAt) / DAY_MS);
if (days < MIN_DAYS_INSTALLED || state.searchCount < MIN_SEARCHES) return;
// Dismissal window: silenced after MAX_DISMISSALS; the first dismissal
// snoozes until `snoozedUntil` before the one final showing.
if (state.dismissCount >= MAX_DISMISSALS) return;
if (state.dismissCount === 1) {
if (state.snoozedUntil === undefined || Date.now() < state.snoozedUntil) return;
}
setNotice({ days, searches: state.searchCount, products: state.totalResults });
} catch (error) {
logger.error(`Failed to evaluate the review prompt: ${getErrorMessage(error)}`, { error });
}
};
void check();
return () => {
cancelled = true;
};
}, []);
const onReview = useCallback(() => {
setNotice(undefined);
chrome.tabs.create({ url: CHROME_WEBSTORE_REVIEWS_URL, active: true });
void markReviewed();
}, []);
const onDismiss = useCallback(() => {
setNotice(undefined);
void snoozeReviewPrompt();
}, []);
return { notice, onReview, onDismiss };
}
|