Allow user to opt out of all unnecessary notifications

This commit is contained in:
Ian Philips 2022-09-30 15:57:01 -06:00
parent 37beb584ef
commit 782ef84080
11 changed files with 218 additions and 50 deletions

View File

@ -96,6 +96,7 @@ type notification_descriptions = {
[key in notification_preference]: { [key in notification_preference]: {
simple: string simple: string
detailed: string detailed: string
necessary?: boolean
} }
} }
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets', detailed: 'Bonuses for unique predictors on your markets',
}, },
your_contract_closed: { your_contract_closed: {
simple: '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', detailed: 'Your market has closed and you need to resolve it (necessary)',
necessary: true,
}, },
all_comments_on_watched_markets: { all_comments_on_watched_markets: {
simple: 'All new comments', simple: 'All new comments',
@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`, simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that 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 = { export type BettingStreakData = {

View File

@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[] profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[] onboarding_flow: notification_destination_types[]
thank_you_for_purchases: 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 = ( export const getDefaultNotificationPreferences = (
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[] return filterDefined([browser, email]) as notification_destination_types[]
} }
return { const defaults: notification_preferences = {
// Watched Markets // Watched Markets
all_comments_on_watched_markets: constructPref(true, false), all_comments_on_watched_markets: constructPref(true, false),
all_answers_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), probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false), thank_you_for_purchases: constructPref(false, false),
onboarding_flow: 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 // 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] ? 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') const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { return {
sendToEmail: destinations.includes('email'), sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser'), sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
} }

View File

@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
}) })
export function formatMoney(amount: number) { export function formatMoney(amount: number) {
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case const newAmount =
// handle -0 case
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
Math.floor(amount + 0.00000000001 * Math.sign(amount))
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }

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 { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences' import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = { export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 }, opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.') res.status(400).send('Invalid subscription type parameter.')
return return
} }
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id) const user = await getPrivateUser(id)
@ -37,14 +40,18 @@ export const unsubscribe: EndpointDefinition = {
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
notificationPreferences: { notificationPreferences: {
...user.notificationPreferences, ...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter( [notificationSubscriptionType]: wantsToOptOutAll
? previousDestinations.push('email')
: previousDestinations.filter(
(destination) => destination !== 'email' (destination) => destination !== 'email'
), ),
}, },
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
res.send( res.send(
` `
<!DOCTYPE html> <!DOCTYPE html>
@ -200,6 +207,10 @@ export const unsubscribe: EndpointDefinition = {
<a href='https://manifold.markets/notifications?tab=settings'>here</a> <a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings. to manage the rest of your notification settings.
</span> </span>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
</div> </div>
</td> </td>

View File

@ -17,6 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -85,7 +86,12 @@ export function BinaryMobileBetting(props: { contract: BinaryContract }) {
if (user) { if (user) {
return <SignedInBinaryMobileBetting contract={contract} user={user} /> return <SignedInBinaryMobileBetting contract={contract} user={user} />
} else { } else {
return <BetSignUpPrompt className="w-full" /> return (
<Col className="w-full">
<BetSignUpPrompt className="w-full" />
<PlayMoneyDisclaimer />
</Col>
)
} }
} }

View File

@ -10,6 +10,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
CurrencyDollarIcon, CurrencyDollarIcon,
ExclamationIcon,
InboxInIcon, InboxInIcon,
InformationCircleIcon, InformationCircleIcon,
LightBulbIcon, LightBulbIcon,
@ -63,6 +64,7 @@ export function NotificationSettings(props: {
'contract_from_followed_user', 'contract_from_followed_user',
'unique_bettors_on_your_contract', 'unique_bettors_on_your_contract',
'profit_loss_updates', 'profit_loss_updates',
'opt_out_all',
// TODO: add these // TODO: add these
// biggest winner, here are the rest of your markets // 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: { function NotificationSettingLine(props: {
description: string description: string
subscriptionTypeKey: notification_preference subscriptionTypeKey: notification_preference
destinations: notification_destination_types[] 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 previousInAppValue = destinations.includes('browser')
const previousEmailValue = destinations.includes('email') const previousEmailValue = destinations.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const [error, setError] = useState<string>('')
const loading = 'Changing Notifications Settings' const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!' const success = 'Changed Notification Settings!'
const highlight = navigateToSection === subscriptionTypeKey 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) => { const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
toast toast
.promise( .promise(
@ -212,18 +250,21 @@ export function NotificationSettings(props: {
{!browserDisabled.includes(subscriptionTypeKey) && ( {!browserDisabled.includes(subscriptionTypeKey) && (
<SwitchSetting <SwitchSetting
checked={inAppEnabled} checked={inAppEnabled}
onChange={(newVal) => changeSetting('browser', newVal)} onChange={(newVal) => attemptToChangeSetting('browser', newVal)}
label={'Web'} label={'Web'}
disabled={optOutAll.includes('browser')}
/> />
)} )}
{emailsEnabled.includes(subscriptionTypeKey) && ( {emailsEnabled.includes(subscriptionTypeKey) && (
<SwitchSetting <SwitchSetting
checked={emailEnabled} checked={emailEnabled}
onChange={(newVal) => changeSetting('email', newVal)} onChange={(newVal) => attemptToChangeSetting('email', newVal)}
label={'Email'} label={'Email'}
disabled={optOutAll.includes('email')}
/> />
)} )}
</Row> </Row>
{error && <span className={'text-error'}>{error}</span>}
</Col> </Col>
</Row> </Row>
) )
@ -283,6 +324,11 @@ export function NotificationSettings(props: {
subType as notification_preference subType as notification_preference
)} )}
description={NOTIFICATION_DESCRIPTIONS[subType].simple} description={NOTIFICATION_DESCRIPTIONS[subType].simple}
optOutAll={
subType === 'opt_out_all' || subType === 'your_contract_closed'
? []
: getUsersSavedPreference('opt_out_all')
}
/> />
))} ))}
</Col> </Col>
@ -332,6 +378,10 @@ export function NotificationSettings(props: {
icon={<InboxInIcon className={'h-6 w-6'} />} icon={<InboxInIcon className={'h-6 w-6'} />}
data={generalOther} data={generalOther}
/> />
<Section
icon={<ExclamationIcon className={'h-6 w-6'} />}
data={optOut}
/>
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} /> <WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col> </Col>
</div> </div>

View File

@ -3,7 +3,7 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money trading" title="Play-money trading"
className="mt-4 max-w-md" className="mt-4"
text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/> />
) )

View File

@ -0,0 +1,47 @@
import { ArrowUpIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Row } from './layout/row'
export function ScrollToTopButton(props: { className?: string }) {
const { className } = props
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => {
if (window.scrollY > 500) {
setVisible(true)
} else {
setVisible(false)
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [])
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
return (
<button
className={clsx(
'border-greyscale-2 bg-greyscale-1 hover:bg-greyscale-2 rounded-full border py-2 pr-3 pl-2 text-sm transition-colors',
visible ? 'inline' : 'hidden',
className
)}
onClick={scrollToTop}
>
<Row className="text-greyscale-6 gap-2 align-middle">
<ArrowUpIcon className="text-greyscale-4 h-5 w-5" />
Scroll to top
</Row>
</button>
)
}

View File

@ -1,22 +1,33 @@
import { Switch } from '@headlessui/react' import { Switch } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import React from 'react' import React from 'react'
import { Tooltip } from 'web/components/tooltip'
export const SwitchSetting = (props: { export const SwitchSetting = (props: {
checked: boolean checked: boolean
onChange: (checked: boolean) => void onChange: (checked: boolean) => void
label: string label: string
disabled: boolean
}) => { }) => {
const { checked, onChange, label } = props const { checked, onChange, label, disabled } = props
return ( return (
<Switch.Group as="div" className="flex items-center"> <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 <Switch
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
className={clsx( className={clsx(
checked ? 'bg-indigo-600' : 'bg-gray-200', 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 <span
aria-hidden="true" aria-hidden="true"
@ -26,8 +37,16 @@ export const SwitchSetting = (props: {
)} )}
/> />
</Switch> </Switch>
</Tooltip>
<Switch.Label as="span" className="ml-3"> <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.Label>
</Switch.Group> </Switch.Group>
) )

View File

@ -42,12 +42,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
import BetButton from 'web/components/bet-button'
import { BetsSummary } from 'web/components/bet-summary' import { BetsSummary } from 'web/components/bet-summary'
import { listAllComments } from 'web/lib/firebase/comments' import { listAllComments } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { ScrollToTopButton } from 'web/components/scroll-to-top-button'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -210,7 +208,6 @@ export function ContractPageContent(
{showConfetti && ( {showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} /> <FullscreenConfetti recycle={false} numberOfPieces={300} />
)} )}
{ogCardProps && ( {ogCardProps && (
<SEO <SEO
title={question} title={question}
@ -219,7 +216,6 @@ export function ContractPageContent(
ogCardProps={ogCardProps} ogCardProps={ogCardProps}
/> />
)} )}
<Col className="w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8"> <Col className="w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8">
{backToHome && ( {backToHome && (
<button <button
@ -276,23 +272,9 @@ export function ContractPageContent(
userBets={userBets} userBets={userBets}
comments={comments} comments={comments}
/> />
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
allowTrade && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</Col> </Col>
<RecommendedContractsWidget contract={contract} /> <RecommendedContractsWidget contract={contract} />
<ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" />
</Page> </Page>
) )
} }