forked from akai/readest
48d52ea898
Fixes #4339. PostHog telemetry was previously enabled by default for every install, which surprised privacy-conscious users on self-hosted setups. Behavior for new users only (existing users are migrated to a decision that preserves their current `telemetryEnabled` setting): - 90% are silently opted out on first launch. - 10% see a one-time consent prompt; accepting opts in, declining opts out. Decision is persisted via a new `readest-telemetry-decision` localStorage key so subsequent boots don't re-roll. PostHog now inits with `opt_out_capturing_by_default` so brand-new users never ping before the decision is finalized. New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral) CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]` without color-mode-only assumptions. Strings translated across all 33 locales. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import '@/utils/polyfill';
|
|
import posthog from 'posthog-js';
|
|
import i18n from '@/i18n/i18n';
|
|
import { useEffect, useState } from 'react';
|
|
import { IconContext } from 'react-icons';
|
|
import { AuthProvider } from '@/context/AuthContext';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { CSPostHogProvider } from '@/context/PHContext';
|
|
import { SyncProvider } from '@/context/SyncContext';
|
|
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useCustomTextureStore } from '@/store/customTextureStore';
|
|
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
|
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
|
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
|
import { useEinkMode } from '@/hooks/useEinkMode';
|
|
import { getLocale } from '@/utils/misc';
|
|
import { getDirFromUILanguage } from '@/utils/rtl';
|
|
import { getAndroidPatchedViewportContent } from '@/utils/viewport';
|
|
import {
|
|
getTelemetryDecision,
|
|
rollIntoTelemetryPromptBucket,
|
|
setTelemetryDecision,
|
|
TELEMETRY_OPT_OUT_KEY,
|
|
} from '@/utils/telemetry';
|
|
import { SETTINGS_FILENAME } from '@/services/constants';
|
|
import type { AppService } from '@/types/system';
|
|
import type { SystemSettings } from '@/types/settings';
|
|
import { DropdownProvider } from '@/context/DropdownContext';
|
|
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
|
|
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
|
|
import AppLockScreen from '@/components/AppLockScreen';
|
|
import AppLockDialog from '@/components/settings/AppLockDialog';
|
|
import PassphrasePrompt from '@/components/PassphrasePrompt';
|
|
import TelemetryConsentDialog from '@/components/TelemetryConsentDialog';
|
|
import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase';
|
|
import { cryptoSession } from '@/libs/crypto/session';
|
|
import { useAppLockStore } from '@/store/appLockStore';
|
|
import { initSettingsSync } from '@/services/sync/replicaSettingsSync';
|
|
|
|
// One-time, on first launch after this feature ships, decide how to handle
|
|
// PostHog telemetry for the current install:
|
|
// - Existing user (settings file already on disk): preserve their current
|
|
// `telemetryEnabled` setting and persist a matching decision so we don't
|
|
// reconsider on every boot.
|
|
// - New user: silently opt 90% out and skip the prompt; show the consent
|
|
// dialog to the remaining 10%. Until the dialog is resolved their state
|
|
// is `pending` (opt-out at the PostHog layer).
|
|
// If a decision has already been recorded, this is a no-op.
|
|
const finalizeTelemetryDecision = ({
|
|
appService,
|
|
settings,
|
|
isNewUser,
|
|
onShowPrompt,
|
|
}: {
|
|
appService: AppService;
|
|
settings: SystemSettings;
|
|
isNewUser: boolean;
|
|
onShowPrompt: () => void;
|
|
}) => {
|
|
const existing = getTelemetryDecision();
|
|
if (existing === 'pending') {
|
|
onShowPrompt();
|
|
return;
|
|
}
|
|
if (existing !== null) return;
|
|
|
|
if (!isNewUser) {
|
|
// Existing user: don't change anything they had set. Sync PostHog to
|
|
// their saved preference and record the decision so we stop checking.
|
|
if (settings.telemetryEnabled) {
|
|
localStorage.setItem(TELEMETRY_OPT_OUT_KEY, 'false');
|
|
posthog.opt_in_capturing();
|
|
setTelemetryDecision('opt-in');
|
|
} else {
|
|
localStorage.setItem(TELEMETRY_OPT_OUT_KEY, 'true');
|
|
posthog.opt_out_capturing();
|
|
setTelemetryDecision('opt-out');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Brand-new user. Default to opt-out for privacy; ask only the prompt
|
|
// bucket so the rest get a friction-free first launch.
|
|
if (rollIntoTelemetryPromptBucket()) {
|
|
setTelemetryDecision('pending');
|
|
onShowPrompt();
|
|
} else {
|
|
localStorage.setItem(TELEMETRY_OPT_OUT_KEY, 'true');
|
|
posthog.opt_out_capturing();
|
|
setTelemetryDecision('opt-out');
|
|
// Persist the off-by-default to the settings file directly. The settings
|
|
// store isn't seeded yet at this point in boot, so saveSysSettings would
|
|
// write a malformed partial object — write through appService instead.
|
|
settings.telemetryEnabled = false;
|
|
void appService.saveSettings(settings);
|
|
}
|
|
};
|
|
|
|
const Providers = ({ children }: { children: React.ReactNode }) => {
|
|
const { envConfig, appService } = useEnv();
|
|
const { applyUILanguage } = useSettingsStore();
|
|
const { applyBackgroundTexture } = useBackgroundTexture();
|
|
const { applyEinkMode } = useEinkMode();
|
|
const {
|
|
isInitialized: isLockInitialized,
|
|
isUnlocked,
|
|
initialize: initializeAppLock,
|
|
} = useAppLockStore();
|
|
const iconSize = useDefaultIconSize();
|
|
const [showTelemetryConsent, setShowTelemetryConsent] = useState(false);
|
|
useSafeAreaInsets(); // Initialize safe area insets
|
|
|
|
useEffect(() => {
|
|
const handlerLanguageChanged = (lng: string) => {
|
|
document.documentElement.lang = lng;
|
|
// Set RTL class on document for targeted styling without affecting layout
|
|
const dir = getDirFromUILanguage();
|
|
if (dir === 'rtl') {
|
|
document.documentElement.classList.add('ui-rtl');
|
|
} else {
|
|
document.documentElement.classList.remove('ui-rtl');
|
|
}
|
|
};
|
|
|
|
const locale = getLocale();
|
|
handlerLanguageChanged(locale);
|
|
i18n.on('languageChanged', handlerLanguageChanged);
|
|
return () => {
|
|
i18n.off('languageChanged', handlerLanguageChanged);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadDataTheme();
|
|
if (appService) {
|
|
initSystemThemeListener(appService);
|
|
const hadSettingsFilePromise = appService.exists(SETTINGS_FILENAME, 'Settings');
|
|
appService.loadSettings().then(async (settings) => {
|
|
const globalViewSettings = settings.globalViewSettings;
|
|
const hadSettingsFile = await hadSettingsFilePromise.catch(() => false);
|
|
finalizeTelemetryDecision({
|
|
appService,
|
|
settings,
|
|
isNewUser: !hadSettingsFile,
|
|
onShowPrompt: () => setShowTelemetryConsent(true),
|
|
});
|
|
applyUILanguage(globalViewSettings.uiLanguage);
|
|
// Seed the customTextureStore with the disk-loaded textures (preserving
|
|
// their saved ids) so the boot-time applyBackgroundTexture below can
|
|
// resolve a custom textureId. Without this, the store is empty until
|
|
// ColorPanel or the replica-pull seed runs — and the in-hook addTexture
|
|
// fallback re-derives the id from name, which mismatches whenever the
|
|
// saved id wasn't computed from the current name (legacy imports,
|
|
// cross-device sync, name-based id collisions).
|
|
if (settings.customTextures?.length) {
|
|
useCustomTextureStore.getState().setTextures(settings.customTextures);
|
|
}
|
|
applyBackgroundTexture(envConfig, globalViewSettings);
|
|
if (globalViewSettings.isEink) {
|
|
applyEinkMode(true);
|
|
}
|
|
// Initialize the app-lock gate from on-disk settings. Until
|
|
// this runs, the gate renders nothing — guarantees the
|
|
// library can't flash on screen before the lock screen does.
|
|
initializeAppLock({
|
|
enabled: !!settings.pinCodeEnabled,
|
|
hash: settings.pinCodeHash,
|
|
salt: settings.pinCodeSalt,
|
|
});
|
|
// Subscribe the bundled-settings publisher to settingsStore
|
|
// changes, AFTER priming the publish snapshot from the just-
|
|
// loaded disk settings. Without this priming, the very first
|
|
// setSettings(disk_default) at boot (typically from library
|
|
// page's initLibrary) would diff every whitelisted field
|
|
// against `undefined`, treat them all as "new", and push the
|
|
// local defaults to the server with a fresh HLC — overwriting
|
|
// the cross-device authoritative values another device set.
|
|
// Idempotent — safe to call on remount.
|
|
initSettingsSync(settings);
|
|
});
|
|
}
|
|
}, [
|
|
envConfig,
|
|
appService,
|
|
applyUILanguage,
|
|
applyBackgroundTexture,
|
|
applyEinkMode,
|
|
initializeAppLock,
|
|
]);
|
|
|
|
// Sync-passphrase boot path: upgrade the passphrase store from
|
|
// ephemeral to OS keychain on Tauri (probe is async — must run after
|
|
// the platform check resolves), then attempt a silent unlock from
|
|
// the saved passphrase. Failures are silent — the gate prompts on
|
|
// first encrypted-field operation if we couldn't restore.
|
|
useEffect(() => {
|
|
void (async () => {
|
|
await upgradeToKeychainIfAvailable();
|
|
await cryptoSession.tryRestoreFromStore();
|
|
})();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const meta = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
|
|
if (!meta) return;
|
|
const updated = getAndroidPatchedViewportContent(navigator.userAgent, meta.content);
|
|
if (updated) meta.content = updated;
|
|
}, []);
|
|
|
|
// Make sure appService is available in all children components
|
|
if (!appService) return;
|
|
|
|
// App-lock gate. While the lock store is uninitialized we render
|
|
// nothing — without this guard the library would flash on screen
|
|
// for a few hundred ms before `loadSettings` resolved and let the
|
|
// lock store decide whether to lock.
|
|
const showAppLockScreen = isLockInitialized && !isUnlocked;
|
|
const appShellHidden = !isLockInitialized || !isUnlocked;
|
|
|
|
return (
|
|
<CSPostHogProvider>
|
|
<AuthProvider>
|
|
<IconContext.Provider value={{ size: `${iconSize}px` }}>
|
|
<SyncProvider>
|
|
<DropdownProvider>
|
|
<CommandPaletteProvider>
|
|
<div
|
|
aria-hidden={appShellHidden}
|
|
style={appShellHidden ? { display: 'none' } : undefined}
|
|
>
|
|
{children}
|
|
<CommandPalette />
|
|
<AtmosphereOverlay />
|
|
<PassphrasePrompt />
|
|
</div>
|
|
<AppLockDialog />
|
|
<TelemetryConsentDialog
|
|
open={showTelemetryConsent}
|
|
onClose={() => setShowTelemetryConsent(false)}
|
|
/>
|
|
{showAppLockScreen && <AppLockScreen />}
|
|
</CommandPaletteProvider>
|
|
</DropdownProvider>
|
|
</SyncProvider>
|
|
</IconContext.Provider>
|
|
</AuthProvider>
|
|
</CSPostHogProvider>
|
|
);
|
|
};
|
|
|
|
export default Providers;
|