Skip to content

Commit 6f770e1

Browse files
committed
feat(sentry): opt-in consent model with first-login banner
- Flip Sentry from opt-out to opt-in: enabled only when sable_sentry_enabled === 'true' - Add TelemetryConsentBanner: fixed bottom slide-up banner shown once on first login - 'Enable crash reports' reloads page so Sentry initialises immediately - 'No thanks' / dismiss X sets sable_sentry_enabled=false, no repeat prompts - Only shown when VITE_SENTRY_DSN is configured (self-hosters unaffected) - Update DiagnosticsAndPrivacy toggle: enable path now sets 'true' instead of removeItem - Update SentrySettings dev panel to match new opt-in read logic
1 parent d13ba92 commit 6f770e1

7 files changed

Lines changed: 174 additions & 5 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { keyframes, style } from '@vanilla-extract/css';
2+
import { color, config, toRem } from 'folds';
3+
4+
const slideUp = keyframes({
5+
from: {
6+
opacity: 0,
7+
transform: 'translateY(100%)',
8+
},
9+
to: {
10+
opacity: 1,
11+
transform: 'translateY(0)',
12+
},
13+
});
14+
15+
const slideDown = keyframes({
16+
from: {
17+
opacity: 1,
18+
transform: 'translateY(0)',
19+
},
20+
to: {
21+
opacity: 0,
22+
transform: 'translateY(100%)',
23+
},
24+
});
25+
26+
export const Container = style({
27+
position: 'fixed',
28+
bottom: 'env(safe-area-inset-bottom, 0)',
29+
left: '50%',
30+
transform: 'translateX(-50%)',
31+
zIndex: 9998,
32+
width: `min(100%, ${toRem(520)})`,
33+
padding: config.space.S400,
34+
pointerEvents: 'none',
35+
});
36+
37+
export const Banner = style({
38+
pointerEvents: 'all',
39+
display: 'flex',
40+
flexDirection: 'column',
41+
gap: config.space.S300,
42+
backgroundColor: color.Surface.Container,
43+
color: color.Surface.OnContainer,
44+
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
45+
borderRadius: toRem(16),
46+
padding: config.space.S400,
47+
boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`,
48+
animationName: slideUp,
49+
animationDuration: '300ms',
50+
animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)',
51+
animationFillMode: 'both',
52+
53+
selectors: {
54+
'&[data-dismissing=true]': {
55+
animationName: slideDown,
56+
animationDuration: '220ms',
57+
animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
58+
animationFillMode: 'both',
59+
},
60+
},
61+
});
62+
63+
export const Header = style({
64+
display: 'flex',
65+
alignItems: 'flex-start',
66+
gap: config.space.S300,
67+
});
68+
69+
export const HeaderText = style({
70+
flex: 1,
71+
minWidth: 0,
72+
display: 'flex',
73+
flexDirection: 'column',
74+
gap: toRem(4),
75+
});
76+
77+
export const Actions = style({
78+
display: 'flex',
79+
gap: config.space.S200,
80+
justifyContent: 'flex-end',
81+
flexWrap: 'wrap',
82+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { Box, Button, Icon, IconButton, Icons, Text } from 'folds';
3+
import * as css from './TelemetryConsentBanner.css';
4+
5+
const SENTRY_KEY = 'sable_sentry_enabled';
6+
7+
export function TelemetryConsentBanner() {
8+
const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN);
9+
const [visible, setVisible] = useState(
10+
isSentryConfigured && localStorage.getItem(SENTRY_KEY) === null
11+
);
12+
const [dismissing, setDismissing] = useState(false);
13+
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14+
15+
useEffect(
16+
() => () => {
17+
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
18+
},
19+
[]
20+
);
21+
22+
if (!visible) return null;
23+
24+
const dismiss = (value: 'true' | 'false') => {
25+
localStorage.setItem(SENTRY_KEY, value);
26+
setDismissing(true);
27+
dismissTimerRef.current = setTimeout(() => setVisible(false), 220);
28+
};
29+
30+
const handleEnable = () => {
31+
localStorage.setItem(SENTRY_KEY, 'true');
32+
window.location.reload();
33+
};
34+
35+
const handleDecline = () => dismiss('false');
36+
37+
return (
38+
<div className={css.Container}>
39+
<div
40+
className={css.Banner}
41+
data-dismissing={dismissing}
42+
role="region"
43+
aria-label="Crash reporting consent"
44+
>
45+
<div className={css.Header}>
46+
<Icon src={Icons.Shield} size="400" />
47+
<div className={css.HeaderText}>
48+
<Text size="H4">Help improve Sable</Text>
49+
<Text size="T300" priority="300">
50+
Send anonymous crash reports to help us fix bugs faster. No messages, room names, or
51+
personal data are included.{' '}
52+
<a
53+
href="https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md"
54+
target="_blank"
55+
rel="noreferrer noopener"
56+
>
57+
Privacy Policy
58+
</a>
59+
</Text>
60+
</div>
61+
<IconButton
62+
size="300"
63+
variant="Surface"
64+
fill="None"
65+
radii="300"
66+
onClick={handleDecline}
67+
aria-label="Dismiss"
68+
>
69+
<Icon size="100" src={Icons.Cross} />
70+
</IconButton>
71+
</div>
72+
<Box className={css.Actions}>
73+
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleDecline}>
74+
<Text size="B300">No thanks</Text>
75+
</Button>
76+
<Button variant="Primary" fill="Solid" size="300" radii="300" onClick={handleEnable}>
77+
<Text size="B300">Enable crash reports</Text>
78+
</Button>
79+
</Box>
80+
</div>
81+
</div>
82+
);
83+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './TelemetryConsentBanner';

src/app/features/settings/developer-tools/SentrySettings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function SentrySettings() {
5050
};
5151

5252
const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN);
53-
const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false';
53+
const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true';
5454
const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE;
5555
const isProd = environment === 'production';
5656
const traceSampleRate = isProd ? '10%' : '100%';

src/app/features/settings/general/General.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,7 +1056,7 @@ type GeneralProps = {
10561056

10571057
function DiagnosticsAndPrivacy() {
10581058
const [sentryEnabled, setSentryEnabled] = useState(
1059-
localStorage.getItem('sable_sentry_enabled') !== 'false'
1059+
localStorage.getItem('sable_sentry_enabled') === 'true'
10601060
);
10611061
const [sessionReplayEnabled, setSessionReplayEnabled] = useState(
10621062
localStorage.getItem('sable_sentry_replay_enabled') === 'true'
@@ -1068,7 +1068,7 @@ function DiagnosticsAndPrivacy() {
10681068
const handleSentryToggle = (enabled: boolean) => {
10691069
setSentryEnabled(enabled);
10701070
if (enabled) {
1071-
localStorage.removeItem('sable_sentry_enabled');
1071+
localStorage.setItem('sable_sentry_enabled', 'true');
10721072
} else {
10731073
localStorage.setItem('sable_sentry_enabled', 'false');
10741074
}

src/app/pages/client/ClientNonUIFeatures.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { mobileOrTablet } from '$utils/user-agent';
4848
import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
4949
import { getSlidingSyncManager } from '$client/initMatrix';
5050
import { NotificationBanner } from '$components/notification-banner';
51+
import { TelemetryConsentBanner } from '$components/telemetry-consent';
5152
import { useCallSignaling } from '$hooks/useCallSignaling';
5253
import { getBlobCacheStats } from '$hooks/useBlobCache';
5354
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
@@ -766,6 +767,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
766767
<BackgroundNotifications />
767768
<SyncNotificationSettingsWithServiceWorker />
768769
<NotificationBanner />
770+
<TelemetryConsentBanner />
769771
<SlidingSyncActiveRoomSubscriber />
770772
<PresenceFeature />
771773
<SentryRoomContextFeature />

src/instrument.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ const release = import.meta.env.VITE_APP_VERSION;
2323
let sessionErrorCount = 0;
2424
const SESSION_ERROR_LIMIT = 50;
2525

26-
// Check user preferences
27-
const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false';
26+
// Check user preferences — opt-in model: Sentry is disabled until the user
27+
// explicitly consents via the first-login banner or Settings.
28+
const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true';
2829
const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true';
2930

3031
/**

0 commit comments

Comments
 (0)