diff --git a/package-lock.json b/package-lock.json
index c50f492..4e3888f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -64,6 +64,7 @@
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"murmurhash": "^2.0.1",
+ "posthog-js": "^1.415.5",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
@@ -2322,8 +2323,6 @@
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
- "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
- "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
@@ -4586,6 +4585,31 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@posthog/browser-common": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz",
+ "integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "^1.47.0",
+ "@posthog/types": "^1.402.2"
+ }
+ },
+ "node_modules/@posthog/core": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
+ "integrity": "sha512-LW62V+9yx7G7mLd+EYpwW0PiaPZI8XRJ1tLuhw+9fXHpugLp8XQD5JuCQ2kIXvoUfyx1DpKfGQY3dUnzMOIn7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "^1.402.2"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.402.3",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz",
+ "integrity": "sha512-nnqKIGUqggeNbCZg6of/hYN+4shYZUoPFkILIIpYq0p9HDX8PgOrnrtBAUH8kr1MOmDh2nm+fY5Ceks7A5y6BQ==",
+ "license": "MIT"
+ },
"node_modules/@prettier/plugin-xml": {
"version": "2.2.0",
"dev": true,
@@ -7391,6 +7415,20 @@
"version": "1.9.0",
"license": "MIT"
},
+ "node_modules/core-js": {
+ "version": "3.50.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
+ "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.45.1",
"license": "MIT",
@@ -7932,6 +7970,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.13",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
+ "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "2.8.0",
"dev": true,
@@ -12018,6 +12065,48 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/posthog-js": {
+ "version": "1.415.5",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.415.5.tgz",
+ "integrity": "sha512-IUmPFtZjwDZYnRmttl+Xo5f/zDVdMdfWdZFxHqtAqq4wMwElr4dIpXmEUzh4nO8NtWT3zBmrL9+c7zZ3avzJZw==",
+ "license": "(Apache-2.0 AND MIT)",
+ "dependencies": {
+ "@posthog/browser-common": "^0.5.0",
+ "@posthog/core": "^1.47.0",
+ "@posthog/types": "^1.402.3",
+ "core-js": "^3.49.0",
+ "dompurify": "^3.4.13",
+ "fflate": "^0.4.8",
+ "preact": "^10.29.3",
+ "query-selector-shadow-dom": "^1.0.1",
+ "web-vitals": "^5.3.0",
+ "web-vitals-soft-navs": "npm:web-vitals@6.0.0"
+ }
+ },
+ "node_modules/posthog-js/node_modules/fflate": {
+ "version": "0.4.9",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
+ "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
+ "license": "MIT"
+ },
+ "node_modules/preact": {
+ "version": "10.29.8",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
+ "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact-render-to-string": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "preact-render-to-string": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"devOptional": true,
@@ -12402,6 +12491,12 @@
],
"license": "MIT"
},
+ "node_modules/query-selector-shadow-dom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
+ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -12466,15 +12561,6 @@
"regenerator-runtime": "^0.13.9"
}
},
- "node_modules/quilljs-markdown/node_modules/core-js": {
- "version": "3.45.1",
- "hasInstallScript": true,
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/core-js"
- }
- },
"node_modules/raf-schd": {
"version": "4.0.3",
"license": "MIT"
@@ -15262,6 +15348,19 @@
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
+ "node_modules/web-vitals": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
+ "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/web-vitals-soft-navs": {
+ "name": "web-vitals",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
+ "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"license": "BSD-2-Clause"
diff --git a/package.json b/package.json
index 28d443c..7a79373 100644
--- a/package.json
+++ b/package.json
@@ -96,6 +96,7 @@
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"murmurhash": "^2.0.1",
+ "posthog-js": "^1.415.5",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
diff --git a/src/App.jsx b/src/App.jsx
index b1a275e..dc1ada6 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -7,6 +7,8 @@ import { useRegisterSW } from 'virtual:pwa-register/react'
import NavBar from '@/views/components/NavBar'
+import { initialize as initializeAnalytics } from './analytics'
+import useAnalyticsIdentity from './analytics/useAnalyticsIdentity'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
@@ -36,6 +38,7 @@ const AppContent = () => {
const { showNotification } = useNotification()
const location = useLocation()
useSyncOnReconnect()
+ useAnalyticsIdentity()
// Every route renders through this Outlet, so one listener here gives crash
// reports the trail that led to the failure.
@@ -142,6 +145,10 @@ function App() {
registerCapacitorListeners(navigate)
}, [navigate])
+ useEffect(() => {
+ initializeAnalytics()
+ }, [])
+
return (
diff --git a/src/analytics/consent.js b/src/analytics/consent.js
new file mode 100644
index 0000000..b9862f0
--- /dev/null
+++ b/src/analytics/consent.js
@@ -0,0 +1,107 @@
+import { Preferences } from '@capacitor/preferences'
+
+// Two independent consent axes, matching the existing onboarding UI
+// (HeardAboutView's PrivacyPreferences): "analytics" gates track(), "crash"
+// gates captureError(). A self-hosted user can opt into one without the other.
+const CONSENT_KEYS = {
+ analytics: 'analytics_consent',
+ crash: 'analytics_crash_consent',
+}
+
+const ANON_ID_KEY = 'analytics_anon_id'
+const INSTALLATION_ID_KEY = 'analytics_installation_id'
+
+const generateUUID = () => {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID()
+ }
+ // Fallback for webviews without crypto.randomUUID — not cryptographically
+ // strong, but this identifier carries no user information either way.
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+ const r = (Math.random() * 16) | 0
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
+ return v.toString(16)
+ })
+}
+
+const readPreference = async key => {
+ try {
+ const { value } = await Preferences.get({ key })
+ return value ?? null
+ } catch {
+ return null
+ }
+}
+
+const writePreference = async (key, value) => {
+ try {
+ await Preferences.set({ key, value })
+ } catch {
+ // best-effort; consent falls back to 'unknown' on next read
+ }
+}
+
+const removePreference = async key => {
+ try {
+ await Preferences.remove({ key })
+ } catch {
+ // ignore
+ }
+}
+
+export const getStoredConsent = async kind => {
+ const value = await readPreference(CONSENT_KEYS[kind])
+ return value === 'enabled' || value === 'disabled' ? value : 'unknown'
+}
+
+export const setStoredConsent = (kind, value) =>
+ writePreference(CONSENT_KEYS[kind], value)
+
+/**
+ * Self-hosted `unknown` behaves as disabled (opt-in required); cloud
+ * `unknown` behaves as enabled (opt-out available). Nothing is persisted by
+ * this resolution alone — only an explicit setConsent() call writes a value.
+ */
+export const resolveEffectiveConsent = (stored, deploymentType) => {
+ if (stored === 'enabled' || stored === 'disabled') return stored
+ return deploymentType === 'cloud' ? 'enabled' : 'disabled'
+}
+
+export const getOrCreateAnonId = async () => {
+ const existing = await readPreference(ANON_ID_KEY)
+ if (existing) return existing
+ const created = generateUUID()
+ await writePreference(ANON_ID_KEY, created)
+ return created
+}
+
+export const getOrCreateInstallationId = async () => {
+ const existing = await readPreference(INSTALLATION_ID_KEY)
+ if (existing) return existing
+ const created = generateUUID()
+ await writePreference(INSTALLATION_ID_KEY, created)
+ return created
+}
+
+/**
+ * Cloud + known user -> identify by the Donetick user id. Self-hosted (or
+ * cloud pre-login) -> a random id containing no user information, never
+ * derived from email/username/database id.
+ */
+export const resolveIdentity = async ({ deploymentType, userId }) => {
+ if (deploymentType === 'cloud' && userId) {
+ return { distinctId: String(userId), installationId: null }
+ }
+ const [anonId, installationId] = await Promise.all([
+ getOrCreateAnonId(),
+ deploymentType === 'cloud' ? null : getOrCreateInstallationId(),
+ ])
+ return { distinctId: anonId, installationId }
+}
+
+export const clearAnonymousIdentity = async () => {
+ await Promise.all([
+ removePreference(ANON_ID_KEY),
+ removePreference(INSTALLATION_ID_KEY),
+ ])
+}
diff --git a/src/analytics/eventSchemas.js b/src/analytics/eventSchemas.js
new file mode 100644
index 0000000..8934d80
--- /dev/null
+++ b/src/analytics/eventSchemas.js
@@ -0,0 +1,139 @@
+// Every event has an explicit property allowlist. Unknown events are
+// dropped entirely; unknown or mistyped properties are dropped individually.
+// This is the mechanism (not just a convention) that keeps user-generated
+// content and PII out of PostHog — see spec.md sections 2, 14, 22.
+
+// Attached to every event so cohort analysis (e.g. "do Plus accounts behave
+// differently") works without every call site having to pass them.
+const COMMON_PROPS = {
+ is_plus_account: 'boolean',
+ circle_member_count: 'number',
+}
+
+const withCommon = props => ({ ...props, ...COMMON_PROPS })
+
+export const EVENT_SCHEMAS = {
+ onboarding_started: withCommon({}),
+ onboarding_completed: withCommon({}),
+ onboarding_skipped: withCommon({}),
+ onboarding_option_selected: withCommon({
+ option: 'string',
+ step: 'string',
+ }),
+
+ chore_created: withCommon({
+ has_due_date: 'boolean',
+ has_assignee: 'boolean',
+ has_labels: 'boolean',
+ has_description: 'boolean',
+ has_recurrence: 'boolean',
+ recurrence_type: 'string',
+ priority: 'number',
+ source: 'string',
+ }),
+
+ analytics_enabled: withCommon({
+ source: 'enum:onboarding,settings',
+ }),
+
+ feedback_prompt_shown: withCommon({
+ source: 'enum:auto,settings',
+ shown_count: 'number',
+ }),
+ feedback_prompt_dismissed: withCommon({
+ source: 'enum:auto,settings',
+ shown_count: 'number',
+ }),
+ feedback_sentiment_selected: withCommon({
+ sentiment: 'enum:love,okay,issues',
+ }),
+ feedback_review_action: withCommon({
+ action: 'enum:github,appStore,playStore',
+ }),
+ feedback_submitted: withCommon({
+ category:
+ 'enum:bugs,missingFeature,tooComplicated,slow,notifications,ai,other',
+ has_message: 'boolean',
+ result: 'enum:sent,failed,unconfigured,misconfigured,self-hosted',
+ }),
+}
+
+export const ERROR_SCHEMAS = {
+ api_error: {
+ http_status: 'string',
+ method: 'string',
+ error_code: 'string',
+ operation: 'string',
+ },
+}
+
+const MAX_STRING_LENGTH = 200
+// Defense in depth: operation/endpoint-like strings must never carry a query
+// string even though callers are expected to have already stripped one.
+const containsQueryOrDisallowedChars = value => /[?&=]/.test(value)
+
+const isValidValue = (value, type) => {
+ if (value === null || value === undefined) return false
+
+ if (type === 'boolean') return typeof value === 'boolean'
+ if (type === 'number') return typeof value === 'number' && !isNaN(value)
+
+ if (type.startsWith('enum:')) {
+ const allowed = type.slice('enum:'.length).split(',')
+ return typeof value === 'string' && allowed.includes(value)
+ }
+
+ if (type === 'string') {
+ return (
+ typeof value === 'string' &&
+ value.length <= MAX_STRING_LENGTH &&
+ !containsQueryOrDisallowedChars(value)
+ )
+ }
+
+ return false
+}
+
+/**
+ * Drops the whole event if its name isn't recognized, then drops any
+ * property that isn't in the schema or fails its type/enum check. Never
+ * throws — a malformed call site loses data, it never crashes the app.
+ */
+export const sanitizeProperties = (schemas, eventName, properties = {}) => {
+ const schema = schemas[eventName]
+ if (!schema) {
+ if (import.meta.env.DEV) {
+ console.warn(`analytics: unknown event "${eventName}", dropping`)
+ }
+ return null
+ }
+
+ const sanitized = {}
+ for (const [key, value] of Object.entries(properties || {})) {
+ const type = schema[key]
+ if (!type) {
+ if (import.meta.env.DEV) {
+ console.warn(
+ `analytics: dropping unknown property "${key}" on "${eventName}"`,
+ )
+ }
+ continue
+ }
+ if (!isValidValue(value, type)) {
+ if (import.meta.env.DEV) {
+ console.warn(
+ `analytics: dropping invalid property "${key}" on "${eventName}"`,
+ )
+ }
+ continue
+ }
+ sanitized[key] = value
+ }
+ return sanitized
+}
+
+export const sanitizeEventProperties = (eventName, properties) =>
+ sanitizeProperties(EVENT_SCHEMAS, eventName, properties)
+
+export const sanitizeErrorProperties = (errorType, properties) =>
+ sanitizeProperties(ERROR_SCHEMAS, errorType, properties)
diff --git a/src/analytics/index.js b/src/analytics/index.js
new file mode 100644
index 0000000..e2e3ea0
--- /dev/null
+++ b/src/analytics/index.js
@@ -0,0 +1,211 @@
+import { getAppVersion, getDeviceContext } from '../utils/DeviceInfo'
+import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
+import {
+ clearAnonymousIdentity,
+ getStoredConsent,
+ resolveEffectiveConsent,
+ resolveIdentity,
+ setStoredConsent,
+} from './consent'
+import {
+ sanitizeErrorProperties,
+ sanitizeEventProperties,
+} from './eventSchemas'
+import { getClient, getClientSync, isConfigured } from './posthogClient'
+
+const state = {
+ initialized: false,
+ initializing: null,
+ deploymentType: null, // 'cloud' | 'self_hosted'
+ consent: { analytics: 'unknown', crash: 'unknown' },
+ distinctId: null,
+ common: { is_plus_account: false, circle_member_count: 0 },
+}
+
+const resolveDeploymentType = async () => {
+ const isCloud = await isOfficialDonetickInstance().catch(() => false)
+ return isCloud ? 'cloud' : 'self_hosted'
+}
+
+const attachBaseProperties = async posthog => {
+ const [appVersion, device] = await Promise.all([
+ getAppVersion(),
+ getDeviceContext(),
+ ])
+ posthog.register({
+ deployment_type: state.deploymentType,
+ app_version: appVersion,
+ platform:
+ typeof window !== 'undefined' && window.Capacitor
+ ? window.Capacitor.getPlatform()
+ : 'web',
+ os: device.osVersion,
+ })
+}
+
+const startPosthog = async () => {
+ const posthog = await getClient()
+ if (!posthog) return null
+
+ const { distinctId } = await resolveIdentity({
+ deploymentType: state.deploymentType,
+ userId: null,
+ })
+ state.distinctId = distinctId
+ posthog.identify(distinctId)
+ posthog.opt_in_capturing()
+ await attachBaseProperties(posthog)
+ return posthog
+}
+
+/**
+ * Reads stored consent, resolves cloud/self-hosted, and starts PostHog only
+ * if the *effective* analytics consent allows it. Safe to call multiple
+ * times; only does real work once.
+ */
+export const initialize = async () => {
+ if (state.initializing) return state.initializing
+ state.initializing = (async () => {
+ state.deploymentType = await resolveDeploymentType()
+
+ const [storedAnalytics, storedCrash] = await Promise.all([
+ getStoredConsent('analytics'),
+ getStoredConsent('crash'),
+ ])
+ state.consent.analytics = storedAnalytics
+ state.consent.crash = storedCrash
+
+ const effectiveAnalytics = resolveEffectiveConsent(
+ storedAnalytics,
+ state.deploymentType,
+ )
+
+ if (isConfigured() && effectiveAnalytics === 'enabled') {
+ await startPosthog()
+ }
+
+ state.initialized = true
+ })()
+ return state.initializing
+}
+
+/** Cloud only meaningfully identifies with the real user id; self-hosted
+ * never sends anything derived from user identity. */
+export const identify = async userId => {
+ if (!state.initialized) await initialize()
+ if (state.deploymentType !== 'cloud' || !userId) return
+
+ const posthog = getClientSync()
+ if (!posthog) return
+
+ state.distinctId = String(userId)
+ posthog.identify(state.distinctId)
+}
+
+/** Kept fresh from the app's own data layer (react-query), not re-fetched by
+ * this module — see useAnalyticsIdentity. Refreshed as super-properties so
+ * every subsequent event carries the latest cohort values without needing to
+ * be passed at every call site. */
+export const updateCommonProperties = ({
+ circle_member_count,
+ is_plus_account,
+} = {}) => {
+ if (typeof is_plus_account === 'boolean') {
+ state.common.is_plus_account = is_plus_account
+ }
+ if (typeof circle_member_count === 'number') {
+ state.common.circle_member_count = circle_member_count
+ }
+
+ const posthog = getClientSync()
+ if (!posthog) return
+ posthog.register({ ...state.common })
+}
+
+const canSend = kind =>
+ state.initialized &&
+ isConfigured() &&
+ resolveEffectiveConsent(state.consent[kind], state.deploymentType) ===
+ 'enabled'
+
+export const track = (eventName, properties = {}) => {
+ if (!canSend('analytics')) return
+ const posthog = getClientSync()
+ if (!posthog) return
+
+ const sanitized = sanitizeEventProperties(eventName, {
+ ...state.common,
+ ...properties,
+ })
+ if (!sanitized) return
+
+ posthog.capture(eventName, sanitized)
+}
+
+export const captureError = (errorType, properties = {}) => {
+ if (!canSend('crash')) return
+ const posthog = getClientSync()
+ if (!posthog) return
+
+ const sanitized = sanitizeErrorProperties(errorType, properties)
+ if (!sanitized) return
+
+ posthog.capture(errorType, sanitized)
+}
+
+/**
+ * kind: 'analytics' | 'crash'. Enabling analytics (re-)initializes PostHog
+ * if needed and sends analytics_enabled; enabling crash-only never talks to
+ * PostHog by itself (it only unlocks captureError once something reports).
+ * Disabling never sends an event and clears identity/queued data.
+ */
+export const setConsent = async (kind, value, { source } = {}) => {
+ if (!state.initialized) await initialize()
+
+ state.consent[kind] = value
+ await setStoredConsent(kind, value)
+
+ if (value === 'disabled') {
+ const posthog = getClientSync()
+ if (posthog) {
+ posthog.opt_out_capturing()
+ posthog.reset()
+ }
+ if (kind === 'analytics' && state.consent.crash !== 'enabled') {
+ await clearAnonymousIdentity()
+ }
+ return
+ }
+
+ // value === 'enabled'
+ if (kind === 'analytics') {
+ if (isConfigured()) {
+ await startPosthog()
+ track('analytics_enabled', { source: source || 'settings' })
+ }
+ } else if (kind === 'crash') {
+ // Crash reporting alone doesn't need PostHog started with the analytics
+ // super-properties path, but it does need a live client + identity to
+ // send captureError() calls through.
+ if (isConfigured() && !getClientSync()) {
+ await startPosthog()
+ }
+ }
+}
+
+export const getConsent = kind =>
+ resolveEffectiveConsent(state.consent[kind], state.deploymentType)
+
+export const getRawConsent = kind => state.consent[kind]
+
+export const getDeploymentType = () => state.deploymentType
+
+export const shutdown = () => {
+ const posthog = getClientSync()
+ if (posthog) {
+ posthog.opt_out_capturing()
+ posthog.reset()
+ }
+ state.initialized = false
+ state.initializing = null
+}
diff --git a/src/analytics/posthogClient.js b/src/analytics/posthogClient.js
new file mode 100644
index 0000000..38b265a
--- /dev/null
+++ b/src/analytics/posthogClient.js
@@ -0,0 +1,35 @@
+// Isolates the posthog-js dependency so index.js never touches the SDK
+// directly — keeps PostHog-specific config in one place and makes it
+// possible to swap/mock the backend later without touching call sites.
+
+let posthog = null
+
+const KEY = import.meta.env.VITE_POSTHOG_KEY
+const HOST = import.meta.env.VITE_POSTHOG_HOST
+
+/** No-ops entirely without a key — covers self-hosted builds from source
+ * that never configured one, and the current empty default. */
+export const isConfigured = () => Boolean(KEY)
+
+export const getClient = async () => {
+ if (!isConfigured()) return null
+ if (posthog) return posthog
+
+ const module = await import('posthog-js')
+ posthog = module.default
+
+ posthog.init(KEY, {
+ api_host: HOST,
+ autocapture: false,
+ capture_pageview: false,
+ capture_pageleave: false,
+ disable_session_recording: true,
+ session_recording: { recorder: undefined },
+ persistence: 'localStorage',
+ opt_out_capturing_by_default: true,
+ })
+
+ return posthog
+}
+
+export const getClientSync = () => posthog
diff --git a/src/analytics/useAnalyticsIdentity.js b/src/analytics/useAnalyticsIdentity.js
new file mode 100644
index 0000000..cceca0c
--- /dev/null
+++ b/src/analytics/useAnalyticsIdentity.js
@@ -0,0 +1,28 @@
+import { useEffect } from 'react'
+
+import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+import { identify, updateCommonProperties } from './index'
+
+/**
+ * Keeps the analytics module's identity and cohort super-properties in sync
+ * with the app's own data layer. Mounted once, high in the tree, alongside
+ * AuthProvider/QueryContext so both queries are already available.
+ */
+const useAnalyticsIdentity = () => {
+ const { data: userProfile } = useUserProfile()
+ const { data: circleMembers } = useCircleMembers()
+
+ useEffect(() => {
+ if (userProfile?.id) identify(userProfile.id)
+ }, [userProfile?.id])
+
+ useEffect(() => {
+ updateCommonProperties({
+ is_plus_account: Boolean(isPlusAccount(userProfile)),
+ circle_member_count: circleMembers?.res?.length ?? 0,
+ })
+ }, [userProfile, circleMembers?.res?.length])
+}
+
+export default useAnalyticsIdentity
diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx
index 190e31e..6c5ef53 100644
--- a/src/contexts/RouterContext.jsx
+++ b/src/contexts/RouterContext.jsx
@@ -42,6 +42,7 @@ import APITokenSettings from '../views/Settings/APITokenSettings'
import LocalizationSettings from '../views/Settings/LocalizationSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'
+import PrivacyAnalyticsSettings from '../views/Settings/PrivacyAnalyticsSettings'
import ProfileSettings from '../views/Settings/ProfileSettings'
import SidepanelSettings from '../views/Settings/SidepanelSettings'
import StorageSettings from '../views/Settings/StorageSettings'
@@ -122,6 +123,10 @@ const Router = createBrowserRouter([
path: 'advanced',
element:
,
},
+ {
+ path: 'privacy',
+ element:
,
+ },
{
path: 'developer',
element:
,
diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx
index 3ca3059..b8aea2e 100644
--- a/src/queries/ChoreQueries.jsx
+++ b/src/queries/ChoreQueries.jsx
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
+
+import { track } from '../analytics'
import { networkManager } from '../hooks/NetworkManager'
import { commandQueue, CommandType } from '../utils/CommandQueue'
import {
@@ -226,6 +228,15 @@ export const useCreateChore = () => {
if (!createdChore) {
throw new Error('Failed to get created chore data')
}
+ track('chore_created', {
+ has_due_date: Boolean(newTask.dueDate),
+ has_assignee: Boolean(newTask.assignedTo),
+ has_labels: Boolean(newTask.labelsV2?.length),
+ has_description: Boolean(newTask.description?.trim()),
+ has_recurrence: newTask.frequencyType !== 'once',
+ recurrence_type: newTask.frequencyType || 'once',
+ priority: typeof newTask.priority === 'number' ? newTask.priority : 0,
+ })
return { ...newTask, id: createdChore.res }
} catch (error) {
if (isNetworkError(error)) {
@@ -458,7 +469,7 @@ export const useUpdateChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async ({ choreId, historyId, historyData }) => {
+ mutationFn: async ({ choreId, historyData, historyId }) => {
const applyOptimisticUpdate = async () => {
queryClient.setQueryData(['choreHistory', choreId], oldData => {
if (!oldData?.res) return oldData
@@ -581,7 +592,7 @@ export const useMarkChoreComplete = () => {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async ({ choreId, body, completedDate, performer }) => {
+ mutationFn: async ({ body, choreId, completedDate, performer }) => {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
diff --git a/src/service/DiagnosticsSession.js b/src/service/DiagnosticsSession.js
index 1592f5b..7d2d3a4 100644
--- a/src/service/DiagnosticsSession.js
+++ b/src/service/DiagnosticsSession.js
@@ -94,7 +94,7 @@ export const getServerVersion = () => serverVersion
// ---------------------------------------------------------------------------
/** Strips ids and query strings so failures group by endpoint, not by row. */
-const normalizeEndpoint = endpoint =>
+export const normalizeEndpoint = endpoint =>
String(endpoint || '')
.split('?')[0]
.replace(/\/\d+/g, '/:id')
diff --git a/src/service/FeedbackService.js b/src/service/FeedbackService.js
index 70e1af7..c514ad2 100644
--- a/src/service/FeedbackService.js
+++ b/src/service/FeedbackService.js
@@ -1,7 +1,8 @@
-import { InAppReview } from '@capacitor-community/in-app-review'
import { Capacitor } from '@capacitor/core'
-import { Device } from '@capacitor/device'
import { Preferences } from '@capacitor/preferences'
+import { InAppReview } from '@capacitor-community/in-app-review'
+
+import { getAppVersion, getDeviceContext } from '../utils/DeviceInfo'
import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
const STATE_KEY = 'feedbackState'
@@ -26,6 +27,9 @@ const defaultState = {
// null until the first prompt is shown/snoozed.
lastPromptedAt: null,
lastPromptedVersion: null,
+ // Cumulative across the device's lifetime, unlike lastPromptedAt/Version
+ // which only remember the most recent showing.
+ shownCount: 0,
dismissCount: 0,
reviewRequestedAt: null,
lastSentiment: null,
@@ -110,31 +114,6 @@ const hasRecentError = () =>
// Context collection
// ---------------------------------------------------------------------------
-const getAppVersion = async () => {
- if (Capacitor.isNativePlatform()) {
- try {
- const { App } = await import('@capacitor/app')
- const info = await App.getInfo()
- return `${info.version} (${info.build})`
- } catch {
- // fall through to the web bundle version
- }
- }
- return import.meta.env.VITE_APP_VERSION || 'web'
-}
-
-const getDeviceContext = async () => {
- try {
- const info = await Device.getInfo()
- return {
- deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
- osVersion: `${info.operatingSystem} ${info.osVersion}`,
- }
- } catch {
- return { deviceModel: 'unknown', osVersion: 'unknown' }
- }
-}
-
/**
* Everything we attach to a submission without asking the user for it.
*/
@@ -251,10 +230,11 @@ export const resetFeedbackState = async () => {
}
export const markPromptShown = async () => {
- const version = await getAppVersion()
+ const [version, state] = await Promise.all([getAppVersion(), readState()])
return writeState({
lastPromptedAt: Date.now(),
lastPromptedVersion: version,
+ shownCount: state.shownCount + 1,
// A forced prompt is spent once shown, otherwise it would fire on every
// visit to My Chores.
devForced: false,
@@ -348,10 +328,10 @@ export const SUBMIT_RESULT = {
* see and edit it before anything is published.
*/
export const buildGithubIssueUrl = ({
- sentiment,
category,
- message,
context,
+ message,
+ sentiment,
}) => {
const labelFor = {
bugs: 'bug',
@@ -402,10 +382,10 @@ export const buildGithubIssueUrl = ({
* pre-filled GitHub issue URL to send the user to instead.
*/
export const submitFeedback = async ({
- sentiment,
category,
- message,
feature,
+ message,
+ sentiment,
userProfile,
}) => {
const context = await collectFeedbackContext({ feature, userProfile })
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 1d78e60..6560c5e 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -1,8 +1,10 @@
import { Preferences } from '@capacitor/preferences'
+import { captureError } from '../analytics'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import {
+ normalizeEndpoint,
recordApiFailure,
recordServerVersionFromResponse,
} from '../service/DiagnosticsSession'
@@ -228,6 +230,11 @@ class ApiClient {
method: config.method,
status: response.status,
})
+ captureError('api_error', {
+ http_status: String(response.status),
+ method: config.method || 'GET',
+ operation: normalizeEndpoint(endpoint),
+ })
}
// 2. Check for 401 (Unauthorized)
@@ -312,6 +319,11 @@ class ApiClient {
if (!externalAbort) {
networkManager.setServerUnreachable()
recordApiFailure({ endpoint, method: config.method, status: 'network' })
+ captureError('api_error', {
+ http_status: 'network',
+ method: config.method || 'GET',
+ operation: normalizeEndpoint(endpoint),
+ })
}
console.error('Request failed', error)
throw error
diff --git a/src/utils/DeviceInfo.js b/src/utils/DeviceInfo.js
new file mode 100644
index 0000000..2a1af26
--- /dev/null
+++ b/src/utils/DeviceInfo.js
@@ -0,0 +1,27 @@
+import { Capacitor } from '@capacitor/core'
+import { Device } from '@capacitor/device'
+
+export const getAppVersion = async () => {
+ if (Capacitor.isNativePlatform()) {
+ try {
+ const { App } = await import('@capacitor/app')
+ const info = await App.getInfo()
+ return `${info.version} (${info.build})`
+ } catch {
+ // fall through to the web bundle version
+ }
+ }
+ return import.meta.env.VITE_APP_VERSION || 'web'
+}
+
+export const getDeviceContext = async () => {
+ try {
+ const info = await Device.getInfo()
+ return {
+ deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
+ osVersion: `${info.operatingSystem} ${info.osVersion}`,
+ }
+ } catch {
+ return { deviceModel: 'unknown', osVersion: 'unknown' }
+ }
+}
diff --git a/src/utils/Onboarding.js b/src/utils/Onboarding.js
index 254af11..57fc9ca 100644
--- a/src/utils/Onboarding.js
+++ b/src/utils/Onboarding.js
@@ -52,22 +52,21 @@ export const recordAcquisitionSource = source => {
}
}
-const PRIVACY_PREFERENCES_KEY = 'privacyPreferences'
-
/**
- * Stashes the self-hosted privacy opt-ins locally, same stub-for-now
- * treatment as recordAcquisitionSource: no crash reporter or PostHog is wired
- * up yet, so this is just the one place that'll change once there is one.
+ * Wires the self-hosted privacy opt-ins into the real analytics module.
+ * Analytics and crash reports are independent consent axes — a user can opt
+ * into one without the other, matching the two separate switches shown on
+ * this screen. Both default to disabled; this only ever runs once the user
+ * has made an explicit choice.
*/
-export const recordPrivacyPreferences = ({ crashReports, analytics }) => {
- try {
- localStorage.setItem(
- PRIVACY_PREFERENCES_KEY,
- JSON.stringify({ crashReports, analytics }),
- )
- } catch {
- // ignore, this is best-effort telemetry
- }
+export const recordPrivacyPreferences = async ({ analytics, crashReports }) => {
+ const { setConsent } = await import('../analytics')
+ await setConsent('analytics', analytics ? 'enabled' : 'disabled', {
+ source: 'onboarding',
+ })
+ await setConsent('crash', crashReports ? 'enabled' : 'disabled', {
+ source: 'onboarding',
+ })
}
/**
@@ -81,9 +80,8 @@ export const recordPrivacyPreferences = ({ crashReports, analytics }) => {
export const requestNotificationPermission = async () => {
if (!isNativeApp()) return false
try {
- const { LocalNotifications } = await import(
- '@capacitor/local-notifications'
- )
+ const { LocalNotifications } =
+ await import('@capacitor/local-notifications')
const { Preferences } = await import('@capacitor/preferences')
const result = await LocalNotifications.requestPermissions()
diff --git a/src/views/Modals/FeedbackModal.jsx b/src/views/Modals/FeedbackModal.jsx
index 04612e2..56354e8 100644
--- a/src/views/Modals/FeedbackModal.jsx
+++ b/src/views/Modals/FeedbackModal.jsx
@@ -1,4 +1,5 @@
import { Browser } from '@capacitor/browser'
+import { Capacitor } from '@capacitor/core'
import { Android, Apple, Favorite, GitHub } from '@mui/icons-material'
import {
Box,
@@ -15,19 +16,21 @@ import {
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
+
+import { track } from '../../analytics'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { useUserProfile } from '../../queries/UserQueries'
import {
FEEDBACK_CATEGORIES,
+ getFeedbackState,
isCloudInstance,
markSentiment,
requestStoreReview,
SENTIMENTS,
storeLinks,
- submitFeedback,
SUBMIT_RESULT,
+ submitFeedback,
} from '../../service/FeedbackService'
-import { Capacitor } from '@capacitor/core'
const STEP = {
SENTIMENT: 'sentiment',
@@ -57,7 +60,7 @@ const SENTIMENT_OPTIONS = [
* dialog (or star links on web); anything else collects structured feedback
* and never asks for a review.
*/
-const FeedbackModal = ({ open, onClose, onDismiss }) => {
+const FeedbackModal = ({ onClose, onDismiss, open, source = 'settings' }) => {
const { t } = useTranslation()
const { ResponsiveModal } = useResponsiveModal()
const { data: userProfile } = useUserProfile()
@@ -70,6 +73,7 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
const [submitting, setSubmitting] = useState(false)
const [isCloud, setIsCloud] = useState(true)
const [githubUrl, setGithubUrl] = useState(null)
+ const [shownCount, setShownCount] = useState(0)
useEffect(() => {
if (open) {
@@ -80,18 +84,28 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
setSubmitting(false)
setGithubUrl(null)
isCloudInstance().then(setIsCloud)
+ getFeedbackState().then(state => {
+ const count = state.shownCount || 0
+ setShownCount(count)
+ track('feedback_prompt_shown', { source, shown_count: count })
+ })
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleClose = () => {
// Backing out before answering counts as a dismissal for the cooldown.
- if (step === STEP.SENTIMENT) onDismiss?.()
+ if (step === STEP.SENTIMENT) {
+ onDismiss?.()
+ track('feedback_prompt_dismissed', { source, shown_count: shownCount })
+ }
onClose()
}
const handleSentiment = async value => {
setSentiment(value)
await markSentiment(value)
+ track('feedback_sentiment_selected', { sentiment: value })
if (value !== SENTIMENTS.LOVE) {
setStep(STEP.DETAILS)
@@ -110,7 +124,7 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
const handleSubmit = async () => {
setSubmitting(true)
- const { result, githubUrl: url } = await submitFeedback({
+ const { githubUrl: url, result } = await submitFeedback({
sentiment,
category,
message,
@@ -118,6 +132,11 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
userProfile,
})
setSubmitting(false)
+ track('feedback_submitted', {
+ category,
+ has_message: message.trim().length > 0,
+ result,
+ })
// Self-hosted feedback is never relayed; hand the user a pre-filled issue
// instead so they choose what gets published.
@@ -248,7 +267,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
variant='outlined'
color='neutral'
startDecorator={
}
- onClick={() => openUrl(storeLinks.github)}
+ onClick={() => {
+ track('feedback_review_action', { action: 'github' })
+ openUrl(storeLinks.github)
+ }}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.github')}
@@ -257,7 +279,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
variant='outlined'
color='neutral'
startDecorator={
}
- onClick={() => openUrl(storeLinks.appStore)}
+ onClick={() => {
+ track('feedback_review_action', { action: 'appStore' })
+ openUrl(storeLinks.appStore)
+ }}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.appStore')}
@@ -266,7 +291,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => {
variant='outlined'
color='neutral'
startDecorator={
}
- onClick={() => openUrl(storeLinks.playStore)}
+ onClick={() => {
+ track('feedback_review_action', { action: 'playStore' })
+ openUrl(storeLinks.playStore)
+ }}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.playStore')}
diff --git a/src/views/Onboarding/HeardAboutView.jsx b/src/views/Onboarding/HeardAboutView.jsx
index a1208b8..3211ad8 100644
--- a/src/views/Onboarding/HeardAboutView.jsx
+++ b/src/views/Onboarding/HeardAboutView.jsx
@@ -2,6 +2,7 @@ import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
+
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
import {
haptic,
@@ -61,7 +62,7 @@ const Shell = ({ children }) => (
/**
* A one-question attribution survey dropped right after account creation,
- * while the "why did I click install" is still fresh. Answering is optional
+ * while the "why did I click install" is still fresh. Answering is optional
* blocking a brand-new user on a marketing question would cost more than the
* data is worth so Continue is always enabled. Shown only on the official
* donetick.com instance: a self-hosted server has no marketing funnel to
@@ -228,8 +229,8 @@ const PrivacyPreferences = ({ onDone }) => {
const values = { crashReports, analytics }
- const finish = () => {
- recordPrivacyPreferences(values)
+ const finish = async () => {
+ await recordPrivacyPreferences(values)
onDone()
}
@@ -264,7 +265,7 @@ const PrivacyPreferences = ({ onDone }) => {
...enter(60),
}}
>
- {PRIVACY_TOGGLES.map(({ key, label, description }) => (
+ {PRIVACY_TOGGLES.map(({ description, key, label }) => (
(
+const Dots = ({ activeIndex, count, onSelect }) => (
{Array.from({ length: count }, (_, index) => {
const active = index === activeIndex
@@ -108,6 +110,10 @@ const OnboardingView = () => {
const isLast = activeIndex === SLIDES.length - 1
const asksPermission = Boolean(SLIDES[activeIndex].permission)
+ useEffect(() => {
+ track('onboarding_started')
+ }, [])
+
const finish = useCallback(() => {
markOnboardingSeen()
navigate('/get-started', { replace: true })
diff --git a/src/views/Onboarding/WorkspaceReadyView.jsx b/src/views/Onboarding/WorkspaceReadyView.jsx
index 8533417..4f363cf 100644
--- a/src/views/Onboarding/WorkspaceReadyView.jsx
+++ b/src/views/Onboarding/WorkspaceReadyView.jsx
@@ -4,6 +4,8 @@ import { Box, Button, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
+
+import { track } from '../../analytics'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { haptic } from '../../utils/Onboarding'
@@ -70,6 +72,7 @@ const WorkspaceReadyView = () => {
console.log('Paywall skipped:', error)
} finally {
setBusy(false)
+ track('onboarding_completed')
enterApp()
}
}
diff --git a/src/views/Settings/PrivacyAnalyticsSettings.jsx b/src/views/Settings/PrivacyAnalyticsSettings.jsx
new file mode 100644
index 0000000..3aa8ee8
--- /dev/null
+++ b/src/views/Settings/PrivacyAnalyticsSettings.jsx
@@ -0,0 +1,86 @@
+import { Box, FormControl, FormHelperText, Switch, Typography } from '@mui/joy'
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { getConsent, initialize, setConsent } from '../../analytics'
+import SettingsLayout from './SettingsLayout'
+
+const TOGGLES = [
+ {
+ kind: 'analytics',
+ labelKey: 'analyticsToggle',
+ helperKey: 'analyticsHelper',
+ },
+ { kind: 'crash', labelKey: 'crashToggle', helperKey: 'crashHelper' },
+]
+
+const PrivacyAnalyticsSettings = () => {
+ const { t } = useTranslation('settings')
+ const [consent, setConsentState] = useState({
+ analytics: 'disabled',
+ crash: 'disabled',
+ })
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ initialize().then(() => {
+ if (cancelled) return
+ setConsentState({
+ analytics: getConsent('analytics'),
+ crash: getConsent('crash'),
+ })
+ setLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const handleToggle = kind => async event => {
+ const next = event.target.checked ? 'enabled' : 'disabled'
+ setConsentState(current => ({ ...current, [kind]: next }))
+ await setConsent(kind, next, { source: 'settings' })
+ }
+
+ return (
+
+
+
+ {t('privacyAnalytics.description')}
+
+
+ {TOGGLES.map(({ helperKey, kind, labelKey }) => (
+
+
+
+ {t(`privacyAnalytics.${labelKey}`)}
+
+
+
+
+ {t(`privacyAnalytics.${helperKey}`)}
+
+
+ ))}
+
+
+ {t('privacyAnalytics.footnote')}
+
+
+
+ )
+}
+
+export default PrivacyAnalyticsSettings
diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx
index 0e5ae66..4a0eb9b 100644
--- a/src/views/Settings/SettingsOverview.jsx
+++ b/src/views/Settings/SettingsOverview.jsx
@@ -11,6 +11,7 @@ import {
Notifications,
Palette,
Person,
+ PrivacyTip,
Security,
Settings,
Star,
@@ -124,6 +125,12 @@ const SettingsOverview = () => {
description: t('overview.sections.advanced.description'),
icon: ,
},
+ {
+ id: 'privacy',
+ title: t('overview.sections.privacy.title'),
+ description: t('overview.sections.privacy.description'),
+ icon: ,
+ },
{
id: 'developer',
title: t('overview.sections.developer.title'),
diff --git a/src/views/components/FeedbackPrompt.jsx b/src/views/components/FeedbackPrompt.jsx
index 72c51e3..38821a8 100644
--- a/src/views/components/FeedbackPrompt.jsx
+++ b/src/views/components/FeedbackPrompt.jsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+
import { useUserProfile } from '../../queries/UserQueries'
import {
installFeedbackErrorListeners,
@@ -31,9 +32,12 @@ const FeedbackPrompt = () => {
shouldShowSentimentPrompt({ userProfile }).then(eligible => {
if (!eligible || cancelled) return
- timer = setTimeout(() => {
+ timer = setTimeout(async () => {
+ if (cancelled) return
+ // Awaited so the persisted shownCount is settled before the modal
+ // reads it back for the feedback_prompt_shown event.
+ await markPromptShown()
if (cancelled) return
- markPromptShown()
setOpen(true)
}, OPEN_DELAY_MS)
})
@@ -51,6 +55,7 @@ const FeedbackPrompt = () => {
open={open}
onClose={() => setOpen(false)}
onDismiss={markPromptDismissed}
+ source='auto'
/>
)
}