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:
Ian Philips 2022-10-03 07:41:39 -06:00 committed by GitHub
parent 1f7b9174b3
commit 051c2905e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 341 additions and 56 deletions

View File

@ -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 = {

View File

@ -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&section=${subscriptionType}`,
}

View 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())

View File

@ -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>
`
)
}
},
}

View File

@ -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>

View File

@ -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>
)