Skip to content

Commit 31bee68

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 31bee68

6 files changed

Lines changed: 168 additions & 2 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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 handleAcknowledge = () => {
25+
localStorage.setItem(SENTRY_KEY, 'true');
26+
setDismissing(true);
27+
dismissTimerRef.current = setTimeout(() => setVisible(false), 220);
28+
};
29+
30+
const handleOptOut = () => {
31+
localStorage.setItem(SENTRY_KEY, 'false');
32+
window.location.reload();
33+
};
34+
35+
return (
36+
<div className={css.Container}>
37+
<div
38+
className={css.Banner}
39+
data-dismissing={dismissing}
40+
role="region"
41+
aria-label="Crash reporting notice"
42+
>
43+
<div className={css.Header}>
44+
<Icon src={Icons.Shield} size="400" />
45+
<div className={css.HeaderText}>
46+
<Text size="H4">Crash reporting is enabled</Text>
47+
<Text size="T300" priority="300">
48+
Sable sends anonymous crash reports to help us fix bugs faster. No messages, room
49+
names, or personal data are included.{' '}
50+
<a
51+
href="https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md"
52+
target="_blank"
53+
rel="noreferrer noopener"
54+
>
55+
Learn more
56+
</a>
57+
</Text>
58+
</div>
59+
<IconButton
60+
size="300"
61+
variant="Surface"
62+
fill="None"
63+
radii="300"
64+
onClick={handleAcknowledge}
65+
aria-label="Dismiss"
66+
>
67+
<Icon size="100" src={Icons.Cross} />
68+
</IconButton>
69+
</div>
70+
<Box className={css.Actions}>
71+
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleOptOut}>
72+
<Text size="B300">Opt out</Text>
73+
</Button>
74+
<Button variant="Primary" fill="Solid" size="300" radii="300" onClick={handleAcknowledge}>
75+
<Text size="B300">Got it</Text>
76+
</Button>
77+
</Box>
78+
</div>
79+
</div>
80+
);
81+
}
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/general/General.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const release = import.meta.env.VITE_APP_VERSION;
2323
let sessionErrorCount = 0;
2424
const SESSION_ERROR_LIMIT = 50;
2525

26-
// Check user preferences
26+
// Default on: Sentry runs unless the user has opted out via the banner or Settings.
2727
const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false';
2828
const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true';
2929

0 commit comments

Comments
 (0)