-
-
-
-
-
\ No newline at end of file
diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html
index c73f7458..bf163f69 100644
--- a/functions/src/email-templates/creating-market.html
+++ b/functions/src/email-templates/creating-market.html
@@ -494,7 +494,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html
index 7c3e653d..0cee6269 100644
--- a/functions/src/email-templates/interesting-markets.html
+++ b/functions/src/email-templates/interesting-markets.html
@@ -443,7 +443,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html
index a19aa7c3..4b98730f 100644
--- a/functions/src/email-templates/market-answer-comment.html
+++ b/functions/src/email-templates/market-answer-comment.html
@@ -529,7 +529,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html
index b2d7f727..e3d42b9d 100644
--- a/functions/src/email-templates/market-answer.html
+++ b/functions/src/email-templates/market-answer.html
@@ -369,7 +369,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html
index ee7976b0..4abd225e 100644
--- a/functions/src/email-templates/market-close.html
+++ b/functions/src/email-templates/market-close.html
@@ -487,7 +487,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html
index 23e20dac..ce0669f1 100644
--- a/functions/src/email-templates/market-comment.html
+++ b/functions/src/email-templates/market-comment.html
@@ -369,7 +369,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html
index ff5f541f..5d886adf 100644
--- a/functions/src/email-templates/market-resolved-no-bets.html
+++ b/functions/src/email-templates/market-resolved-no-bets.html
@@ -470,7 +470,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html
index de29a0f1..767202b6 100644
--- a/functions/src/email-templates/market-resolved.html
+++ b/functions/src/email-templates/market-resolved.html
@@ -502,7 +502,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html
index 877d554f..49633fb2 100644
--- a/functions/src/email-templates/new-market-from-followed-user.html
+++ b/functions/src/email-templates/new-market-from-followed-user.html
@@ -318,7 +318,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html
index 30da8b99..51026121 100644
--- a/functions/src/email-templates/new-unique-bettor.html
+++ b/functions/src/email-templates/new-unique-bettor.html
@@ -376,7 +376,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html
index eb4c04e2..09c44d03 100644
--- a/functions/src/email-templates/new-unique-bettors.html
+++ b/functions/src/email-templates/new-unique-bettors.html
@@ -480,7 +480,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html
index b8e233d5..e7d14a7e 100644
--- a/functions/src/email-templates/one-week.html
+++ b/functions/src/email-templates/one-week.html
@@ -283,7 +283,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html
index 7ac72d0a..beef11ee 100644
--- a/functions/src/email-templates/thank-you.html
+++ b/functions/src/email-templates/thank-you.html
@@ -218,7 +218,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html
index dccec695..d6caaa0c 100644
--- a/functions/src/email-templates/welcome.html
+++ b/functions/src/email-templates/welcome.html
@@ -290,7 +290,7 @@
click here to manage your notifications.
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index bb9f7195..98309ebe 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Contract } from '../../common/contract'
-import {
- notification_subscription_types,
- PrivateUser,
- User,
-} from '../../common/user'
+import { PrivateUser, User } from '../../common/user'
import {
formatLargeNumber,
formatMoney,
@@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
-import {
- notification_reason_types,
- getDestinationsForUser,
-} from '../../common/notification'
+import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
+import {
+ getNotificationDestinationsForUser,
+ notification_preference,
+} from '../../common/user-notification-preferences'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser || !privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
@@ -154,7 +153,7 @@ export const sendWelcomeEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
@@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
privateUser.email,
@@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
privateUser.email,
@@ -289,7 +288,7 @@ export const sendThankYouEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'thank_you_for_purchases' as keyof notification_subscription_types
+ 'thank_you_for_purchases' as notification_preference
}`
return await sendTemplateEmail(
@@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser,
contract: Contract
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
@@ -350,8 +351,10 @@ export const sendNewCommentEmail = async (
answerText?: string,
answerId?: string
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser || !privateUser.email || !sendToEmail) return
const { question } = contract
@@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async (
// Don't send the creator's own answers.
if (privateUser.id === creatorId) return
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
@@ -465,7 +470,7 @@ export const sendInterestingMarketsEmail = async (
return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'trending_markets' as keyof notification_subscription_types
+ 'trending_markets' as notification_preference
}`
const { name } = user
@@ -516,8 +521,10 @@ export const sendNewFollowedMarketEmail = async (
privateUser: PrivateUser,
contract: Contract
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
if (!user) return
@@ -553,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async (
userBets: Dictionary<[Bet, ...Bet[]]>,
bonusAmount: number
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
if (!user) return
diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts
index 2796f2f7..4ba2e25e 100644
--- a/functions/src/scripts/create-new-notification-preferences.ts
+++ b/functions/src/scripts/create-new-notification-preferences.ts
@@ -1,8 +1,8 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
-import { getDefaultNotificationSettings } from 'common/user'
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
+import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
initAdmin()
const firestore = admin.firestore()
@@ -17,7 +17,7 @@ async function main() {
.collection('private-users')
.doc(privateUser.id)
.update({
- notificationPreferences: getDefaultNotificationSettings(
+ notificationPreferences: getDefaultNotificationPreferences(
privateUser.id,
privateUser,
disableEmails
diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts
index 21e117cf..762e801a 100644
--- a/functions/src/scripts/create-private-users.ts
+++ b/functions/src/scripts/create-private-users.ts
@@ -3,8 +3,9 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
-import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
+import { PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy'
+import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
const firestore = admin.firestore()
@@ -21,7 +22,7 @@ async function main() {
id: user.id,
email,
username,
- notificationPreferences: getDefaultNotificationSettings(user.id),
+ notificationPreferences: getDefaultNotificationPreferences(user.id),
}
if (user.totalDeposits === undefined) {
diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts
index da7b507f..418282c7 100644
--- a/functions/src/unsubscribe.ts
+++ b/functions/src/unsubscribe.ts
@@ -1,79 +1,227 @@
import * as admin from 'firebase-admin'
import { EndpointDefinition } from './api'
-import { getUser } from './utils'
+import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user'
+import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
+import { notification_preference } from '../../common/user-notification-preferences'
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
handler: async (req, res) => {
const id = req.query.id as string
- let type = req.query.type as string
+ const type = req.query.type as string
if (!id || !type) {
- res.status(400).send('Empty id or type parameter.')
+ res.status(400).send('Empty id or subscription type parameter.')
+ return
+ }
+ console.log(`Unsubscribing ${id} from ${type}`)
+ const notificationSubscriptionType = type as notification_preference
+ if (notificationSubscriptionType === undefined) {
+ res.status(400).send('Invalid subscription type parameter.')
return
}
- if (type === 'market-resolved') type = 'market-resolve'
-
- if (
- ![
- 'market-resolve',
- 'market-comment',
- 'market-answer',
- 'generic',
- 'weekly-trending',
- ].includes(type)
- ) {
- res.status(400).send('Invalid type parameter.')
- return
- }
-
- const user = await getUser(id)
+ const user = await getPrivateUser(id)
if (!user) {
res.send('This user is not currently subscribed or does not exist.')
return
}
- const { name } = user
+ const previousDestinations =
+ user.notificationPreferences[notificationSubscriptionType]
+
+ console.log(previousDestinations)
+ const { email } = user
const update: Partial = {
- ...(type === 'market-resolve' && {
- unsubscribedFromResolutionEmails: true,
- }),
- ...(type === 'market-comment' && {
- unsubscribedFromCommentEmails: true,
- }),
- ...(type === 'market-answer' && {
- unsubscribedFromAnswerEmails: true,
- }),
- ...(type === 'generic' && {
- unsubscribedFromGenericEmails: true,
- }),
- ...(type === 'weekly-trending' && {
- unsubscribedFromWeeklyTrendingEmails: true,
- }),
+ notificationPreferences: {
+ ...user.notificationPreferences,
+ [notificationSubscriptionType]: previousDestinations.filter(
+ (destination) => destination !== 'email'
+ ),
+ },
}
await firestore.collection('private-users').doc(id).update(update)
- if (type === 'market-resolve')
- res.send(
- `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
- )
- else if (type === 'market-comment')
- res.send(
- `${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
- )
- else if (type === 'market-answer')
- res.send(
- `${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
- )
- else if (type === 'weekly-trending')
- res.send(
- `${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
- )
- else res.send(`${name}, you have been unsubscribed.`)
+ res.send(
+ `
+
+
+
+
+ Manifold Markets 7th Day Anniversary Gift!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${email} has been unsubscribed from email notifications related to:
+
+
+
+
+ ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.
+
+
+
+
+ Click
+ here
+ to manage the rest of your notification settings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ )
},
}
diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx
index d18896bd..8730ce7f 100644
--- a/web/components/notification-settings.tsx
+++ b/web/components/notification-settings.tsx
@@ -1,11 +1,7 @@
import React, { memo, ReactNode, useEffect, useState } from 'react'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
-import {
- notification_subscription_types,
- notification_destination_types,
- PrivateUser,
-} from 'common/user'
+import { PrivateUser } from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import {
@@ -30,6 +26,11 @@ import {
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
+import { NOTIFICATION_DESCRIPTIONS } from 'common/notification'
+import {
+ notification_destination_types,
+ notification_preference,
+} from 'common/user-notification-preferences'
export function NotificationSettings(props: {
navigateToSection: string | undefined
@@ -38,7 +39,7 @@ export function NotificationSettings(props: {
const { navigateToSection, privateUser } = props
const [showWatchModal, setShowWatchModal] = useState(false)
- const emailsEnabled: Array = [
+ const emailsEnabled: Array = [
'all_comments_on_watched_markets',
'all_replies_to_my_comments_on_watched_markets',
'all_comments_on_contracts_with_shares_in_on_watched_markets',
@@ -74,7 +75,7 @@ export function NotificationSettings(props: {
// 'probability_updates_on_watched_markets',
// 'limit_order_fills',
]
- const browserDisabled: Array = [
+ const browserDisabled: Array = [
'trending_markets',
'profit_loss_updates',
'onboarding_flow',
@@ -83,91 +84,82 @@ export function NotificationSettings(props: {
type SectionData = {
label: string
- subscriptionTypeToDescription: {
- [key in keyof Partial]: string
- }
+ subscriptionTypes: Partial[]
}
const comments: SectionData = {
label: 'New Comments',
- subscriptionTypeToDescription: {
- all_comments_on_watched_markets: 'All new comments',
- all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
+ subscriptionTypes: [
+ 'all_comments_on_watched_markets',
+ 'all_comments_on_contracts_with_shares_in_on_watched_markets',
// TODO: combine these two
- all_replies_to_my_comments_on_watched_markets:
- 'Only replies to your comments',
- all_replies_to_my_answers_on_watched_markets:
- 'Only replies to your answers',
- // comments_by_followed_users_on_watched_markets: 'By followed users',
- },
+ 'all_replies_to_my_comments_on_watched_markets',
+ 'all_replies_to_my_answers_on_watched_markets',
+ ],
}
const answers: SectionData = {
label: 'New Answers',
- subscriptionTypeToDescription: {
- all_answers_on_watched_markets: 'All new answers',
- all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
- // answers_by_followed_users_on_watched_markets: 'By followed users',
- // answers_by_market_creator_on_watched_markets: 'By market creator',
- },
+ subscriptionTypes: [
+ 'all_answers_on_watched_markets',
+ 'all_answers_on_contracts_with_shares_in_on_watched_markets',
+ ],
}
const updates: SectionData = {
label: 'Updates & Resolutions',
- subscriptionTypeToDescription: {
- market_updates_on_watched_markets: 'All creator updates',
- market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`,
- resolutions_on_watched_markets: 'All market resolutions',
- resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`,
- // probability_updates_on_watched_markets: 'Probability updates',
- },
+ subscriptionTypes: [
+ 'market_updates_on_watched_markets',
+ 'market_updates_on_watched_markets_with_shares_in',
+ 'resolutions_on_watched_markets',
+ 'resolutions_on_watched_markets_with_shares_in',
+ ],
}
const yourMarkets: SectionData = {
label: 'Markets You Created',
- subscriptionTypeToDescription: {
- your_contract_closed: 'Your market has closed (and needs resolution)',
- all_comments_on_my_markets: 'Comments on your markets',
- all_answers_on_my_markets: 'Answers on your markets',
- subsidized_your_market: 'Your market was subsidized',
- tips_on_your_markets: 'Likes on your markets',
- },
+ subscriptionTypes: [
+ 'your_contract_closed',
+ 'all_comments_on_my_markets',
+ 'all_answers_on_my_markets',
+ 'subsidized_your_market',
+ 'tips_on_your_markets',
+ ],
}
const bonuses: SectionData = {
label: 'Bonuses',
- subscriptionTypeToDescription: {
- betting_streaks: 'Prediction streak bonuses',
- referral_bonuses: 'Referral bonuses from referring users',
- unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
- },
+ subscriptionTypes: [
+ 'betting_streaks',
+ 'referral_bonuses',
+ 'unique_bettors_on_your_contract',
+ ],
}
const otherBalances: SectionData = {
label: 'Other',
- subscriptionTypeToDescription: {
- loan_income: 'Automatic loans from your profitable bets',
- limit_order_fills: 'Limit order fills',
- tips_on_your_comments: 'Tips on your comments',
- },
+ subscriptionTypes: [
+ 'loan_income',
+ 'limit_order_fills',
+ 'tips_on_your_comments',
+ ],
}
const userInteractions: SectionData = {
label: 'Users',
- subscriptionTypeToDescription: {
- tagged_user: 'A user tagged you',
- on_new_follow: 'Someone followed you',
- contract_from_followed_user: 'New markets created by users you follow',
- },
+ subscriptionTypes: [
+ 'tagged_user',
+ 'on_new_follow',
+ 'contract_from_followed_user',
+ ],
}
const generalOther: SectionData = {
label: 'Other',
- subscriptionTypeToDescription: {
- trending_markets: 'Weekly interesting markets',
- thank_you_for_purchases: 'Thank you notes for your purchases',
- onboarding_flow: 'Explanatory emails to help you get started',
- // profit_loss_updates: 'Weekly profit/loss updates',
- },
+ subscriptionTypes: [
+ 'trending_markets',
+ 'thank_you_for_purchases',
+ 'onboarding_flow',
+ ],
}
function NotificationSettingLine(props: {
description: string
- subscriptionTypeKey: keyof notification_subscription_types
+ subscriptionTypeKey: notification_preference
destinations: notification_destination_types[]
}) {
const { description, subscriptionTypeKey, destinations } = props
@@ -237,9 +229,7 @@ export function NotificationSettings(props: {
)
}
- const getUsersSavedPreference = (
- key: keyof notification_subscription_types
- ) => {
+ const getUsersSavedPreference = (key: notification_preference) => {
return privateUser.notificationPreferences[key] ?? []
}
@@ -248,17 +238,17 @@ export function NotificationSettings(props: {
data: SectionData
}) {
const { icon, data } = props
- const { label, subscriptionTypeToDescription } = data
+ const { label, subscriptionTypes } = data
const expand =
navigateToSection &&
- Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
+ Object.keys(subscriptionTypes).includes(navigateToSection)
// Not sure how to prevent re-render (and collapse of an open section)
// due to a private user settings change. Just going to persist expanded state here
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
key:
'NotificationsSettingsSection-' +
- Object.keys(subscriptionTypeToDescription).join('-'),
+ Object.keys(subscriptionTypes).join('-'),
store: storageStore(safeLocalStorage()),
})
@@ -287,13 +277,13 @@ export function NotificationSettings(props: {
)}
- {Object.entries(subscriptionTypeToDescription).map(([key, value]) => (
+ {subscriptionTypes.map((subType) => (
))}
From 9aa56dd19300e084361d14cc606ac690aa434f7d Mon Sep 17 00:00:00 2001
From: Ian Philips
Date: Wed, 14 Sep 2022 17:25:17 -0600
Subject: [PATCH 04/42] Only show prev opened notif setting section
---
web/components/notification-settings.tsx | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx
index 8730ce7f..b806dfb2 100644
--- a/web/components/notification-settings.tsx
+++ b/web/components/notification-settings.tsx
@@ -241,14 +241,12 @@ export function NotificationSettings(props: {
const { label, subscriptionTypes } = data
const expand =
navigateToSection &&
- Object.keys(subscriptionTypes).includes(navigateToSection)
+ subscriptionTypes.includes(navigateToSection as notification_preference)
// Not sure how to prevent re-render (and collapse of an open section)
// due to a private user settings change. Just going to persist expanded state here
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
- key:
- 'NotificationsSettingsSection-' +
- Object.keys(subscriptionTypes).join('-'),
+ key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'),
store: storageStore(safeLocalStorage()),
})
From ccf02bdba8f565e94fbb01fb9ac5cb7c9de93fc2 Mon Sep 17 00:00:00 2001
From: ingawei <46611122+ingawei@users.noreply.github.com>
Date: Wed, 14 Sep 2022 22:28:40 -0500
Subject: [PATCH 05/42] Inga/admin rules resolve (#880)
* Giving admin permission to resolve all markets that have closed after 7 days.
---
common/envs/constants.ts | 5 ++-
common/envs/prod.ts | 1 +
firestore.rules | 3 +-
functions/src/resolve-market.ts | 9 ++++--
.../answers/answer-resolve-panel.tsx | 20 ++++++++++--
web/components/answers/answers-panel.tsx | 28 ++++++++++-------
web/components/numeric-resolution-panel.tsx | 20 +++++++++---
web/components/resolution-panel.tsx | 11 +++++--
web/pages/[username]/[contractSlug].tsx | 31 +++++++++++++++----
9 files changed, 99 insertions(+), 29 deletions(-)
diff --git a/common/envs/constants.ts b/common/envs/constants.ts
index ba460d58..0502322a 100644
--- a/common/envs/constants.ts
+++ b/common/envs/constants.ts
@@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) {
}
// TODO: Before open sourcing, we should turn these into env vars
-export function isAdmin(email: string) {
+export function isAdmin(email?: string) {
+ if (!email) {
+ return false
+ }
return ENV_CONFIG.adminEmails.includes(email)
}
diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index b3b552eb..6bf781b7 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -74,6 +74,7 @@ export const PROD_CONFIG: EnvConfig = {
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede
+ 'ingawei@gmail.com', //Inga
],
visibility: 'PUBLIC',
diff --git a/firestore.rules b/firestore.rules
index 6f2ea90a..08214b10 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -14,7 +14,8 @@ service cloud.firestore {
'manticmarkets@gmail.com',
'iansphilips@gmail.com',
'd4vidchee@gmail.com',
- 'federicoruizcassarino@gmail.com'
+ 'federicoruizcassarino@gmail.com',
+ 'ingawei@gmail.com'
]
}
diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts
index b867b609..44293898 100644
--- a/functions/src/resolve-market.ts
+++ b/functions/src/resolve-market.ts
@@ -16,7 +16,7 @@ import {
groupPayoutsByUser,
Payout,
} from '../../common/payouts'
-import { isManifoldId } from '../../common/envs/constants'
+import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
@@ -76,13 +76,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract
+ const firebaseUser = await admin.auth().getUser(auth.uid)
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
- if (creatorId !== auth.uid && !isManifoldId(auth.uid))
+ if (
+ creatorId !== auth.uid &&
+ !isManifoldId(auth.uid) &&
+ !isAdmin(firebaseUser.email)
+ )
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx
index 0a4ac1e1..4594ea35 100644
--- a/web/components/answers/answer-resolve-panel.tsx
+++ b/web/components/answers/answer-resolve-panel.tsx
@@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: {
+ isAdmin: boolean
+ isCreator: boolean
contract: FreeResponseContract | MultipleChoiceContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: (
@@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: {
) => void
chosenAnswers: { [answerId: string]: number }
}) {
- const { contract, resolveOption, setResolveOption, chosenAnswers } = props
+ const {
+ contract,
+ resolveOption,
+ setResolveOption,
+ chosenAnswers,
+ isAdmin,
+ isCreator,
+ } = props
const answers = Object.keys(chosenAnswers)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: {
return (
+ )
+})
+
+// Just to keep the formatting pretty
+export { M as MentionList }
diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts
new file mode 100644
index 00000000..39d024e7
--- /dev/null
+++ b/web/components/editor/contract-mention-suggestion.ts
@@ -0,0 +1,27 @@
+import type { MentionOptions } from '@tiptap/extension-mention'
+import { searchInAny } from 'common/util/parse'
+import { orderBy } from 'lodash'
+import { getCachedContracts } from 'web/hooks/use-contracts'
+import { MentionList } from './contract-mention-list'
+import { PluginKey } from 'prosemirror-state'
+import { makeMentionRender } from './mention-suggestion'
+
+type Suggestion = MentionOptions['suggestion']
+
+const beginsWith = (text: string, query: string) =>
+ text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
+
+export const contractMentionSuggestion: Suggestion = {
+ char: '%',
+ allowSpaces: true,
+ pluginKey: new PluginKey('contract-mention'),
+ items: async ({ query }) =>
+ orderBy(
+ (await getCachedContracts()).filter((c) =>
+ searchInAny(query, c.question)
+ ),
+ [(c) => [c.question].some((s) => beginsWith(s, query))],
+ ['desc', 'desc']
+ ).slice(0, 5),
+ render: makeMentionRender(MentionList),
+}
diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx
new file mode 100644
index 00000000..ddc81bc0
--- /dev/null
+++ b/web/components/editor/contract-mention.tsx
@@ -0,0 +1,42 @@
+import Mention from '@tiptap/extension-mention'
+import {
+ mergeAttributes,
+ NodeViewWrapper,
+ ReactNodeViewRenderer,
+} from '@tiptap/react'
+import clsx from 'clsx'
+import { useContract } from 'web/hooks/use-contract'
+import { ContractCard } from '../contract/contract-card'
+
+const name = 'contract-mention-component'
+
+const ContractMentionComponent = (props: any) => {
+ const contract = useContract(props.node.attrs.id)
+
+ return (
+
+ {contract && (
+
+ )}
+
+ )
+}
+
+/**
+ * Mention extension that renders React. See:
+ * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
+ * https://tiptap.dev/guide/node-views/react#render-a-react-component
+ */
+export const DisplayContractMention = Mention.extend({
+ name: 'contract-mention',
+ parseHTML: () => [{ tag: name }],
+ renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
+ addNodeView: () =>
+ ReactNodeViewRenderer(ContractMentionComponent, {
+ // On desktop, render cards below half-width so you can stack two
+ className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1',
+ }),
+})
diff --git a/web/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts
index 9f016d47..b4eeeebe 100644
--- a/web/components/editor/mention-suggestion.ts
+++ b/web/components/editor/mention-suggestion.ts
@@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list'
+type Render = Suggestion['render']
type Suggestion = MentionOptions['suggestion']
@@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
],
['desc', 'desc']
).slice(0, 5),
- render: () => {
+ render: makeMentionRender(MentionList),
+}
+
+export function makeMentionRender(mentionList: any): Render {
+ return () => {
let component: ReactRenderer
let popup: ReturnType
return {
onStart: (props) => {
- component = new ReactRenderer(MentionList, {
+ component = new ReactRenderer(mentionList, {
props,
editor: props.editor,
})
@@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = {
})
},
onKeyDown(props) {
- if (props.event.key === 'Escape') {
- popup?.[0].hide()
- return true
- }
+ if (props.event.key)
+ if (
+ props.event.key === 'Escape' ||
+ // Also break out of the mention if the tooltip isn't visible
+ (props.event.key === 'Enter' && !popup?.[0].state.isShown)
+ ) {
+ popup?.[0].destroy()
+ component?.destroy()
+ return false
+ }
return (component?.ref as any)?.onKeyDown(props)
},
onExit() {
@@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
component?.destroy()
},
}
- },
+ }
}
diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts
index 1ea2f232..87eefa38 100644
--- a/web/hooks/use-contracts.ts
+++ b/web/hooks/use-contracts.ts
@@ -9,8 +9,9 @@ import {
listenForNewContracts,
getUserBetContracts,
getUserBetContractsQuery,
+ listAllContracts,
} from 'web/lib/firebase/contracts'
-import { useQueryClient } from 'react-query'
+import { QueryClient, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
export const useContracts = () => {
@@ -23,6 +24,12 @@ export const useContracts = () => {
return contracts
}
+const q = new QueryClient()
+export const getCachedContracts = async () =>
+ q.fetchQuery(['contracts'], () => listAllContracts(1000), {
+ staleTime: Infinity,
+ })
+
export const useActiveContracts = () => {
const [activeContracts, setActiveContracts] = useState<
Contract[] | undefined
diff --git a/web/package.json b/web/package.json
index 114ded1e..ba25a6e1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -48,6 +48,7 @@
"nanoid": "^3.3.4",
"next": "12.2.5",
"node-fetch": "3.2.4",
+ "prosemirror-state": "1.4.1",
"react": "17.0.2",
"react-beautiful-dnd": "13.1.1",
"react-confetti": "6.0.1",
From 833ec518b42b82e53a5a1ae3010ecfba75171adb Mon Sep 17 00:00:00 2001
From: Phil
Date: Fri, 16 Sep 2022 08:22:13 +0100
Subject: [PATCH 34/42] Twitch prerelease (#882)
* Bot linking button functional.
* Implemented initial prototype of new Twitch signup page.
* Removed old Twitch signup page.
* Moved new Twitch page to correct URL.
* Twitch account linking functional.
* Fixed charity link.
* Changed to point to live bot server.
* Slightly improve spacing and alignment on Twitch page
* Tidy up, handle some errors when talking to bot
* Seriously do the thing where Twitch link is hidden by default
Co-authored-by: Marshall Polaris
---
web/components/profile/twitch-panel.tsx | 95 +++++++---
web/lib/twitch/link-twitch-account.ts | 53 ++++--
web/pages/profile.tsx | 6 +-
web/pages/twitch.tsx | 230 ++++++++++++++++++++++--
web/public/twitch-glitch.svg | 21 +++
5 files changed, 347 insertions(+), 58 deletions(-)
create mode 100644 web/public/twitch-glitch.svg
diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx
index b284b242..a37b21dc 100644
--- a/web/components/profile/twitch-panel.tsx
+++ b/web/components/profile/twitch-panel.tsx
@@ -6,38 +6,101 @@ import { LinkIcon } from '@heroicons/react/solid'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
-import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
+import {
+ linkTwitchAccountRedirect,
+ updateBotEnabledForUser,
+} from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
import { Button, ColorType } from './../button'
import { Row } from './../layout/row'
import { LoadingIndicator } from './../loading-indicator'
+import { PrivateUser } from 'common/user'
function BouncyButton(props: {
children: ReactNode
onClick?: MouseEventHandler
color?: ColorType
+ className?: string
}) {
- const { children, onClick, color } = props
+ const { children, onClick, color, className } = props
return (
{children}
)
}
+function BotConnectButton(props: {
+ privateUser: PrivateUser | null | undefined
+}) {
+ const { privateUser } = props
+ const [loading, setLoading] = useState(false)
+
+ const updateBotConnected = (connected: boolean) => async () => {
+ if (!privateUser) return
+ const twitchInfo = privateUser.twitchInfo
+ if (!twitchInfo) return
+
+ const error = connected
+ ? 'Failed to add bot to your channel'
+ : 'Failed to remove bot from your channel'
+ const success = connected
+ ? 'Added bot to your channel'
+ : 'Removed bot from your channel'
+
+ setLoading(true)
+ toast.promise(
+ updateBotEnabledForUser(privateUser, connected).then(() =>
+ updatePrivateUser(privateUser.id, {
+ twitchInfo: { ...twitchInfo, botEnabled: connected },
+ })
+ ),
+ { loading: 'Updating bot settings...', error, success }
+ )
+ try {
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <>
+ {privateUser?.twitchInfo?.botEnabled ? (
+
+ Remove bot from your channel
+
+ ) : (
+
+ Add bot to your channel
+
+ )}
+ >
+ )
+}
+
export function TwitchPanel() {
const user = useUser()
const privateUser = usePrivateUser()
const twitchInfo = privateUser?.twitchInfo
- const twitchName = privateUser?.twitchInfo?.twitchName
- const twitchToken = privateUser?.twitchInfo?.controlToken
- const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
+ const twitchName = twitchInfo?.twitchName
+ const twitchToken = twitchInfo?.controlToken
const linkIcon =
@@ -55,13 +118,6 @@ export function TwitchPanel() {
})
}
- const updateBotConnected = (connected: boolean) => async () => {
- if (user && twitchInfo) {
- twitchInfo.botEnabled = connected
- await updatePrivateUser(user.id, { twitchInfo })
- }
- }
-
const [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => {
@@ -115,17 +171,12 @@ export function TwitchPanel() {
Copy dock link
- {twitchBotConnected ? (
-
- Remove bot from your channel
-
- ) : (
-
- Add bot to your channel
-
- )}
+
+
+
+
)}
>
diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts
index 36fb12b5..71bc847d 100644
--- a/web/lib/twitch/link-twitch-account.ts
+++ b/web/lib/twitch/link-twitch-account.ts
@@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
+async function postToBot(url: string, body: unknown) {
+ const result = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const json = await result.json()
+ if (!result.ok) {
+ throw new Error(json.message)
+ } else {
+ return json
+ }
+}
+
export async function initLinkTwitchAccount(
manifoldUserID: string,
manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
- const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- manifoldID: manifoldUserID,
- apiKey: manifoldUserAPIKey,
- redirectURL: window.location.href,
- }),
+ const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
+ manifoldID: manifoldUserID,
+ apiKey: manifoldUserAPIKey,
+ redirectURL: window.location.href,
})
- const responseData = await response.json()
- if (!response.ok) {
- throw new Error(responseData.message)
- }
const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
)
- return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
+ return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
}
export async function linkTwitchAccountRedirect(
@@ -39,3 +43,22 @@ export async function linkTwitchAccountRedirect(
window.location.href = twitchAuthURL
}
+
+export async function updateBotEnabledForUser(
+ privateUser: PrivateUser,
+ botEnabled: boolean
+) {
+ if (botEnabled) {
+ return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
+ apiKey: privateUser.apiKey,
+ }).then((r) => {
+ if (!r.success) throw new Error(r.message)
+ })
+ } else {
+ return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
+ apiKey: privateUser.apiKey,
+ }).then((r) => {
+ if (!r.success) throw new Error(r.message)
+ })
+ }
+}
diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx
index 6b70b5d2..44a63b2d 100644
--- a/web/pages/profile.tsx
+++ b/web/pages/profile.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline'
-
+import { useRouter } from 'next/router'
import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
@@ -64,6 +64,7 @@ function EditUserField(props: {
export default function ProfilePage(props: {
auth: { user: User; privateUser: PrivateUser }
}) {
+ const router = useRouter()
const { user, privateUser } = props.auth
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
const [avatarLoading, setAvatarLoading] = useState(false)
@@ -237,8 +238,7 @@ export default function ProfilePage(props: {
-
-
+ {router.query.twitch && }
diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx
index 7ca892e8..a21c1105 100644
--- a/web/pages/twitch.tsx
+++ b/web/pages/twitch.tsx
@@ -1,29 +1,34 @@
+import { PrivateUser, User } from 'common/user'
+import Link from 'next/link'
import { useState } from 'react'
-import { Page } from 'web/components/page'
+import toast from 'react-hot-toast'
+import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
-import { ManifoldLogo } from 'web/components/nav/manifold-logo'
-import { useSaveReferral } from 'web/hooks/use-save-referral'
-import { SEO } from 'web/components/SEO'
+import { Row } from 'web/components/layout/row'
import { Spacer } from 'web/components/layout/spacer'
+import { LoadingIndicator } from 'web/components/loading-indicator'
+import { ManifoldLogo } from 'web/components/nav/manifold-logo'
+import { Page } from 'web/components/page'
+import { SEO } from 'web/components/SEO'
+import { Title } from 'web/components/title'
+import { useSaveReferral } from 'web/hooks/use-save-referral'
+import { useTracking } from 'web/hooks/use-tracking'
+import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
-import { Row } from 'web/components/layout/row'
-import { Button } from 'web/components/button'
-import { useTracking } from 'web/hooks/use-tracking'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
-import { usePrivateUser, useUser } from 'web/hooks/use-user'
-import { LoadingIndicator } from 'web/components/loading-indicator'
-import toast from 'react-hot-toast'
-export default function TwitchLandingPage() {
- useSaveReferral()
- useTracking('view twitch landing page')
+function TwitchPlaysManifoldMarkets(props: {
+ user?: User | null
+ privateUser?: PrivateUser | null
+}) {
+ const { user, privateUser } = props
- const user = useUser()
- const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName
+ const [isLoading, setLoading] = useState(false)
+
const callback =
user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser)
@@ -37,8 +42,6 @@ export default function TwitchLandingPage() {
await linkTwitchAccountRedirect(user, privateUser)
}
- const [isLoading, setLoading] = useState(false)
-
const getStarted = async () => {
try {
setLoading(true)
@@ -53,6 +56,191 @@ export default function TwitchLandingPage() {
}
}
+ return (
+
+
+
+
+
+
+
+ Similar to Twitch channel point predictions, Manifold Markets allows
+ you to create and feature on stream any question you like with users
+ predicting to earn play money.
+
+
+ The key difference is that Manifold's questions function more like a
+ stock market and viewers can buy and sell shares over the course of
+ the event and not just at the start. The market will eventually
+ resolve to yes or no at which point the winning shareholders will
+ receive their profit.
+
+ Start playing now by logging in with Google and typing commands in chat!
+ {twitchUser ? (
+
+ Account connected: {twitchUser}
+
+ ) : isLoading ? (
+
+ ) : (
+
+ Start playing
+
+ )}
+
+ Instead of Twitch channel points we use our play money, mana (m$). All
+ viewers start with M$1000 and more can be earned for free and then{' '}
+
+ donated to a charity
+ {' '}
+ of their choice at no cost!
+