Allow user to opt out of all unnecessary notifications (#974)
* Allow user to opt out of all unnecessary notifications * Unsubscribe from all response ux * Only send one response
This commit is contained in:
parent
1f7b9174b3
commit
051c2905e1
|
@ -96,6 +96,7 @@ type notification_descriptions = {
|
|||
[key in notification_preference]: {
|
||||
simple: string
|
||||
detailed: string
|
||||
necessary?: boolean
|
||||
}
|
||||
}
|
||||
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||
|
@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
detailed: 'Bonuses for unique predictors on your markets',
|
||||
},
|
||||
your_contract_closed: {
|
||||
simple: 'Your market has closed and you need to resolve it',
|
||||
detailed: 'Your market has closed and you need to resolve it',
|
||||
simple: 'Your market has closed and you need to resolve it (necessary)',
|
||||
detailed: 'Your market has closed and you need to resolve it (necessary)',
|
||||
necessary: true,
|
||||
},
|
||||
all_comments_on_watched_markets: {
|
||||
simple: 'All new comments',
|
||||
|
@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
simple: `Only on markets you're invested in`,
|
||||
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||
},
|
||||
opt_out_all: {
|
||||
simple: 'Opt out of all notifications (excludes when your markets close)',
|
||||
detailed:
|
||||
'Opt out of all notifications excluding your own market closure notifications',
|
||||
},
|
||||
}
|
||||
|
||||
export type BettingStreakData = {
|
||||
|
|
|
@ -53,6 +53,9 @@ export type notification_preferences = {
|
|||
profit_loss_updates: notification_destination_types[]
|
||||
onboarding_flow: notification_destination_types[]
|
||||
thank_you_for_purchases: notification_destination_types[]
|
||||
|
||||
opt_out_all: notification_destination_types[]
|
||||
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
||||
}
|
||||
|
||||
export const getDefaultNotificationPreferences = (
|
||||
|
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
|
|||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||
return filterDefined([browser, email]) as notification_destination_types[]
|
||||
}
|
||||
return {
|
||||
const defaults: notification_preferences = {
|
||||
// Watched Markets
|
||||
all_comments_on_watched_markets: constructPref(true, false),
|
||||
all_answers_on_watched_markets: constructPref(true, false),
|
||||
|
@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = (
|
|||
probability_updates_on_watched_markets: constructPref(true, false),
|
||||
thank_you_for_purchases: constructPref(false, false),
|
||||
onboarding_flow: constructPref(false, false),
|
||||
} as notification_preferences
|
||||
|
||||
opt_out_all: [],
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||
|
@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = (
|
|||
? notificationSettings[subscriptionType]
|
||||
: []
|
||||
}
|
||||
const optOutOfAllSettings = notificationSettings['opt_out_all']
|
||||
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
|
||||
const optedOutOfEmail =
|
||||
optOutOfAllSettings.includes('email') &&
|
||||
subscriptionType !== 'your_contract_closed'
|
||||
const optedOutOfBrowser =
|
||||
optOutOfAllSettings.includes('browser') &&
|
||||
subscriptionType !== 'your_contract_closed'
|
||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||
return {
|
||||
sendToEmail: destinations.includes('email'),
|
||||
sendToBrowser: destinations.includes('browser'),
|
||||
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
}
|
||||
|
|
27
functions/src/scripts/add-new-notification-preference.ts
Normal file
27
functions/src/scripts/add-new-notification-preference.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
await Promise.all(
|
||||
privateUsers.map((privateUser) => {
|
||||
if (!privateUser.id) return Promise.resolve()
|
||||
return firestore
|
||||
.collection('private-users')
|
||||
.doc(privateUser.id)
|
||||
.update({
|
||||
notificationPreferences: {
|
||||
...privateUser.notificationPreferences,
|
||||
opt_out_all: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) main().then(() => process.exit())
|
|
@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
|
|||
import { PrivateUser } from '../../common/user'
|
||||
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||
import { notification_preference } from '../../common/user-notification-preferences'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
|
||||
export const unsubscribe: EndpointDefinition = {
|
||||
opts: { method: 'GET', minInstances: 1 },
|
||||
|
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
|
|||
res.status(400).send('Invalid subscription type parameter.')
|
||||
return
|
||||
}
|
||||
const optOutAllType: notification_preference = 'opt_out_all'
|
||||
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
|
||||
|
||||
const user = await getPrivateUser(id)
|
||||
|
||||
|
@ -37,14 +40,172 @@ export const unsubscribe: EndpointDefinition = {
|
|||
const update: Partial<PrivateUser> = {
|
||||
notificationPreferences: {
|
||||
...user.notificationPreferences,
|
||||
[notificationSubscriptionType]: previousDestinations.filter(
|
||||
[notificationSubscriptionType]: wantsToOptOutAll
|
||||
? previousDestinations.push('email')
|
||||
: previousDestinations.filter(
|
||||
(destination) => destination !== 'email'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||
|
||||
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
|
||||
if (wantsToOptOutAll) {
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||
<div style="background-color:#F4F4F4;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
${email} has opted out of receiving unnecessary email notifications
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p></p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
)
|
||||
} else {
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
|
@ -197,6 +358,12 @@ export const unsubscribe: EndpointDefinition = {
|
|||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href=${optOutAllUrl}>here</a>
|
||||
to unsubscribe from all unnecessary emails.
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||
to manage the rest of your notification settings.
|
||||
</span>
|
||||
|
@ -222,6 +389,7 @@ export const unsubscribe: EndpointDefinition = {
|
|||
</html>
|
||||
`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CurrencyDollarIcon,
|
||||
ExclamationIcon,
|
||||
InboxInIcon,
|
||||
InformationCircleIcon,
|
||||
LightBulbIcon,
|
||||
|
@ -63,6 +64,7 @@ export function NotificationSettings(props: {
|
|||
'contract_from_followed_user',
|
||||
'unique_bettors_on_your_contract',
|
||||
'profit_loss_updates',
|
||||
'opt_out_all',
|
||||
// TODO: add these
|
||||
// biggest winner, here are the rest of your markets
|
||||
|
||||
|
@ -157,20 +159,56 @@ export function NotificationSettings(props: {
|
|||
],
|
||||
}
|
||||
|
||||
const optOut: SectionData = {
|
||||
label: 'Opt Out',
|
||||
subscriptionTypes: ['opt_out_all'],
|
||||
}
|
||||
|
||||
function NotificationSettingLine(props: {
|
||||
description: string
|
||||
subscriptionTypeKey: notification_preference
|
||||
destinations: notification_destination_types[]
|
||||
optOutAll: notification_destination_types[]
|
||||
}) {
|
||||
const { description, subscriptionTypeKey, destinations } = props
|
||||
const { description, subscriptionTypeKey, destinations, optOutAll } = props
|
||||
const previousInAppValue = destinations.includes('browser')
|
||||
const previousEmailValue = destinations.includes('email')
|
||||
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
|
||||
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
|
||||
const [error, setError] = useState<string>('')
|
||||
const loading = 'Changing Notifications Settings'
|
||||
const success = 'Changed Notification Settings!'
|
||||
const highlight = navigateToSection === subscriptionTypeKey
|
||||
|
||||
const attemptToChangeSetting = (
|
||||
setting: 'browser' | 'email',
|
||||
newValue: boolean
|
||||
) => {
|
||||
const necessaryError =
|
||||
'This notification type is necessary. At least one destination must be enabled.'
|
||||
const necessarySetting =
|
||||
NOTIFICATION_DESCRIPTIONS[subscriptionTypeKey].necessary
|
||||
if (
|
||||
necessarySetting &&
|
||||
setting === 'browser' &&
|
||||
!emailEnabled &&
|
||||
!newValue
|
||||
) {
|
||||
setError(necessaryError)
|
||||
return
|
||||
} else if (
|
||||
necessarySetting &&
|
||||
setting === 'email' &&
|
||||
!inAppEnabled &&
|
||||
!newValue
|
||||
) {
|
||||
setError(necessaryError)
|
||||
return
|
||||
}
|
||||
|
||||
changeSetting(setting, newValue)
|
||||
}
|
||||
|
||||
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
|
||||
toast
|
||||
.promise(
|
||||
|
@ -212,18 +250,21 @@ export function NotificationSettings(props: {
|
|||
{!browserDisabled.includes(subscriptionTypeKey) && (
|
||||
<SwitchSetting
|
||||
checked={inAppEnabled}
|
||||
onChange={(newVal) => changeSetting('browser', newVal)}
|
||||
onChange={(newVal) => attemptToChangeSetting('browser', newVal)}
|
||||
label={'Web'}
|
||||
disabled={optOutAll.includes('browser')}
|
||||
/>
|
||||
)}
|
||||
{emailsEnabled.includes(subscriptionTypeKey) && (
|
||||
<SwitchSetting
|
||||
checked={emailEnabled}
|
||||
onChange={(newVal) => changeSetting('email', newVal)}
|
||||
onChange={(newVal) => attemptToChangeSetting('email', newVal)}
|
||||
label={'Email'}
|
||||
disabled={optOutAll.includes('email')}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
{error && <span className={'text-error'}>{error}</span>}
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
|
@ -283,6 +324,11 @@ export function NotificationSettings(props: {
|
|||
subType as notification_preference
|
||||
)}
|
||||
description={NOTIFICATION_DESCRIPTIONS[subType].simple}
|
||||
optOutAll={
|
||||
subType === 'opt_out_all' || subType === 'your_contract_closed'
|
||||
? []
|
||||
: getUsersSavedPreference('opt_out_all')
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
|
@ -332,6 +378,10 @@ export function NotificationSettings(props: {
|
|||
icon={<InboxInIcon className={'h-6 w-6'} />}
|
||||
data={generalOther}
|
||||
/>
|
||||
<Section
|
||||
icon={<ExclamationIcon className={'h-6 w-6'} />}
|
||||
data={optOut}
|
||||
/>
|
||||
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
|
||||
</Col>
|
||||
</div>
|
||||
|
|
|
@ -1,22 +1,33 @@
|
|||
import { Switch } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { Tooltip } from 'web/components/tooltip'
|
||||
|
||||
export const SwitchSetting = (props: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label: string
|
||||
disabled: boolean
|
||||
}) => {
|
||||
const { checked, onChange, label } = props
|
||||
const { checked, onChange, label, disabled } = props
|
||||
return (
|
||||
<Switch.Group as="div" className="flex items-center">
|
||||
<Tooltip
|
||||
text={
|
||||
disabled
|
||||
? `You are opted out of all ${label} notifications. Go to the Opt Out section to undo this setting.`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
checked ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
@ -26,8 +37,16 @@ export const SwitchSetting = (props: {
|
|||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Tooltip>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-medium text-gray-900',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user