feat(analytics): integrate PostHog for event tracking and user consent management
- Added PostHog SDK to package.json for analytics tracking. - Implemented analytics consent management with separate toggles for analytics and crash reporting. - Created analytics module to handle initialization, event tracking, and user identification. - Integrated analytics tracking into various components including onboarding, feedback prompts, and chore creation. - Added PrivacyAnalyticsSettings view for managing user consent preferences. - Enhanced feedback submission with analytics tracking for user interactions. - Updated device information utility for better context in analytics events. - Refactored existing code to utilize new analytics functions and ensure proper event sanitization.
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<NetworkBanner />
|
||||
|
||||
107
src/analytics/consent.js
Normal file
107
src/analytics/consent.js
Normal file
@@ -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),
|
||||
])
|
||||
}
|
||||
139
src/analytics/eventSchemas.js
Normal file
139
src/analytics/eventSchemas.js
Normal file
@@ -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)
|
||||
211
src/analytics/index.js
Normal file
211
src/analytics/index.js
Normal file
@@ -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
|
||||
}
|
||||
35
src/analytics/posthogClient.js
Normal file
35
src/analytics/posthogClient.js
Normal file
@@ -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
|
||||
28
src/analytics/useAnalyticsIdentity.js
Normal file
28
src/analytics/useAnalyticsIdentity.js
Normal file
@@ -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
|
||||
@@ -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: <AdvancedSettings />,
|
||||
},
|
||||
{
|
||||
path: 'privacy',
|
||||
element: <PrivacyAnalyticsSettings />,
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
element: <DeveloperSettings />,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
27
src/utils/DeviceInfo.js
Normal file
27
src/utils/DeviceInfo.js
Normal file
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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={<GitHub />}
|
||||
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={<Apple />}
|
||||
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={<Android />}
|
||||
onClick={() => openUrl(storeLinks.playStore)}
|
||||
onClick={() => {
|
||||
track('feedback_review_action', { action: 'playStore' })
|
||||
openUrl(storeLinks.playStore)
|
||||
}}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{t('feedback.review.playStore')}
|
||||
|
||||
@@ -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 }) => (
|
||||
<Box
|
||||
key={key}
|
||||
sx={{
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
haptic,
|
||||
@@ -63,7 +65,7 @@ const SLIDES = [
|
||||
},
|
||||
]
|
||||
|
||||
const Dots = ({ count, activeIndex, onSelect }) => (
|
||||
const Dots = ({ activeIndex, count, onSelect }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||
{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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
86
src/views/Settings/PrivacyAnalyticsSettings.jsx
Normal file
86
src/views/Settings/PrivacyAnalyticsSettings.jsx
Normal file
@@ -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 (
|
||||
<SettingsLayout title={t('privacyAnalytics.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
{t('privacyAnalytics.description')}
|
||||
</Typography>
|
||||
|
||||
{TOGGLES.map(({ helperKey, kind, labelKey }) => (
|
||||
<FormControl key={kind} sx={{ mt: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{t(`privacyAnalytics.${labelKey}`)}
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={consent[kind] === 'enabled'}
|
||||
onChange={handleToggle(kind)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
{t(`privacyAnalytics.${helperKey}`)}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
))}
|
||||
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('privacyAnalytics.footnote')}
|
||||
</Typography>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivacyAnalyticsSettings
|
||||
@@ -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: <Settings />,
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
title: t('overview.sections.privacy.title'),
|
||||
description: t('overview.sections.privacy.description'),
|
||||
icon: <PrivacyTip />,
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: t('overview.sections.developer.title'),
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user