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]: {
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

@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
})
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('$', '')
}

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,18 @@ 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}`
res.send(
`
<!DOCTYPE html>
@ -200,6 +207,10 @@ export const unsubscribe: EndpointDefinition = {
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
</div>
</td>

View File

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

@ -3,7 +3,7 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => (
<InfoBox
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!"
/>
)

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

View File

@ -42,12 +42,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch'
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 { listAllComments } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment'
import { ScrollToTopButton } from 'web/components/scroll-to-top-button'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -210,7 +208,6 @@ export function ContractPageContent(
{showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
)}
{ogCardProps && (
<SEO
title={question}
@ -219,7 +216,6 @@ export function ContractPageContent(
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">
{backToHome && (
<button
@ -276,23 +272,9 @@ export function ContractPageContent(
userBets={userBets}
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>
<RecommendedContractsWidget contract={contract} />
<ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" />
</Page>
)
}