[WIP] Fully customizable notifications (#860)

* Notifications Settings page working

* Update import

* Linked notification settings to notification rules

* Add more subscribe types

* It's alive... It's alive, it's moving, it's alive, it's alive, it's alive, it's alive, IT'S ALIVE'

* UI Tweaks

* Clean up comments

* Direct & highlight sections for notif mgmt from emails

* Comment cleanup

* Comment cleanup, lint

* More comment cleanup

* Update email templates to predict

* Move private user out of getDestinationsForUser

* Fix resolution messages

* Remove magic

* Extract switch to switch-setting

* Change tab in url

* Show 0 as invested or payout

* All emails use unsubscribeUrl
pull/873/head
Ian Philips 2 years ago committed by GitHub
parent 28f0c6b1f8
commit 5c6328ffc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,3 +1,6 @@
import { notification_subscription_types, PrivateUser } from './user'
import { DOMAIN } from './envs/constants'
export type Notification = {
id: string
userId: string
@ -51,28 +54,106 @@ export type notification_source_update_types =
| 'deleted'
| 'closed'
/* Optional - if possible use a keyof notification_subscription_types */
export type notification_reason_types =
| 'tagged_user'
| 'on_users_contract'
| 'on_contract_with_users_shares_in'
| 'on_contract_with_users_shares_out'
| 'on_contract_with_users_answer'
| 'on_contract_with_users_comment'
| 'reply_to_users_answer'
| 'reply_to_users_comment'
| 'on_new_follow'
| 'you_follow_user'
| 'added_you_to_group'
| 'contract_from_followed_user'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of'
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'
| 'liked_your_contract'
| 'liked_and_tipped_your_contract'
| 'comment_on_your_contract'
| 'answer_on_your_contract'
| 'comment_on_contract_you_follow'
| 'answer_on_contract_you_follow'
| 'update_on_contract_you_follow'
| 'resolution_on_contract_you_follow'
| 'comment_on_contract_with_users_shares_in'
| 'answer_on_contract_with_users_shares_in'
| 'update_on_contract_with_users_shares_in'
| 'resolution_on_contract_with_users_shares_in'
| 'comment_on_contract_with_users_answer'
| 'update_on_contract_with_users_answer'
| 'resolution_on_contract_with_users_answer'
| 'answer_on_contract_with_users_answer'
| 'comment_on_contract_with_users_comment'
| 'answer_on_contract_with_users_comment'
| 'update_on_contract_with_users_comment'
| 'resolution_on_contract_with_users_comment'
| 'reply_to_users_answer'
| 'reply_to_users_comment'
| 'your_contract_closed'
| 'subsidized_your_market'
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
// You might want to add a key:value here if there will be multiple notification reasons that map to the same
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
// 'all_comments_on_watched_markets' subscription type
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
export const notificationReasonToSubscriptionType: Partial<
Record<notification_reason_types, keyof notification_subscription_types>
> = {
you_referred_user: 'referral_bonuses',
user_joined_to_bet_on_your_market: 'referral_bonuses',
tip_received: 'tips_on_your_comments',
bet_fill: 'limit_order_fills',
user_joined_from_your_group_invite: 'referral_bonuses',
challenge_accepted: 'limit_order_fills',
betting_streak_incremented: 'betting_streaks',
liked_and_tipped_your_contract: 'tips_on_your_markets',
comment_on_your_contract: 'all_comments_on_my_markets',
answer_on_your_contract: 'all_answers_on_my_markets',
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
update_on_contract_you_follow: 'market_updates_on_watched_markets',
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
comment_on_contract_with_users_shares_in:
'all_comments_on_contracts_with_shares_in_on_watched_markets',
answer_on_contract_with_users_shares_in:
'all_answers_on_contracts_with_shares_in_on_watched_markets',
update_on_contract_with_users_shares_in:
'market_updates_on_watched_markets_with_shares_in',
resolution_on_contract_with_users_shares_in:
'resolutions_on_watched_markets_with_shares_in',
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
}
export const getDestinationsForUser = async (
privateUser: PrivateUser,
reason: notification_reason_types | keyof notification_subscription_types
) => {
const notificationSettings = privateUser.notificationSubscriptionTypes
let destinations
let subscriptionType: keyof notification_subscription_types | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as keyof notification_subscription_types
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
}

@ -1,3 +1,5 @@
import { filterDefined } from './util/array'
export type User = {
id: string
createdTime: number
@ -63,9 +65,60 @@ export type PrivateUser = {
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
/** @deprecated - use notificationSubscriptionTypes */
notificationPreferences?: notification_subscribe_types
notificationSubscriptionTypes: notification_subscription_types
}
export type notification_destination_types = 'email' | 'browser'
export type notification_subscription_types = {
// Watched Markets
all_comments_on_watched_markets: notification_destination_types[]
all_answers_on_watched_markets: notification_destination_types[]
// Comments
tipped_comments_on_watched_markets: notification_destination_types[]
comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers
answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets
your_contract_closed: notification_destination_types[]
all_comments_on_my_markets: notification_destination_types[]
all_answers_on_my_markets: notification_destination_types[]
subsidized_your_market: notification_destination_types[]
// Market updates
resolutions_on_watched_markets: notification_destination_types[]
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
market_updates_on_watched_markets: notification_destination_types[]
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[]
// Balance Changes
loan_income: notification_destination_types[]
betting_streaks: notification_destination_types[]
referral_bonuses: notification_destination_types[]
unique_bettors_on_your_contract: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General
tagged_user: notification_destination_types[]
on_new_follow: notification_destination_types[]
contract_from_followed_user: notification_destination_types[]
trending_markets: notification_destination_types[]
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
}
export type notification_subscribe_types = 'all' | 'less' | 'none'
export type PortfolioMetrics = {
@ -78,3 +131,140 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const getDefaultNotificationSettings = (
userId: string,
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const prevPref = privateUser?.notificationPreferences ?? 'all'
const wantsLess = prevPref === 'less'
const wantsAll = prevPref === 'all'
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
comments_by_followed_users_on_watched_markets: constructPref(
wantsAll,
false
),
all_replies_to_my_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// On users' markets
your_contract_closed: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(wantsAll || wantsLess, true),
// Market updates
resolutions_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
market_updates_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(wantsAll || wantsLess, false),
betting_streaks: constructPref(wantsAll || wantsLess, false),
referral_bonuses: constructPref(wantsAll || wantsLess, true),
unique_bettors_on_your_contract: constructPref(
wantsAll || wantsLess,
false
),
tipped_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(wantsAll || wantsLess, true),
limit_order_fills: constructPref(wantsAll || wantsLess, false),
// General
tagged_user: constructPref(wantsAll || wantsLess, true),
on_new_follow: constructPref(wantsAll || wantsLess, true),
contract_from_followed_user: constructPref(wantsAll || wantsLess, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_subscription_types
}

@ -2,10 +2,30 @@
"functions": {
"predeploy": "cd functions && yarn build",
"runtime": "nodejs16",
"source": "functions/dist"
"source": "functions/dist",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log"
]
},
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"pubsub": {
"port": 8085
},
"ui": {
"enabled": true
}
}
}

@ -77,7 +77,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
}
match /private-users/{userId}/views/{viewId} {
@ -161,7 +161,7 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isSeen', 'viewTime']);
}
match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
@ -170,7 +170,7 @@ service cloud.firestore {
allow read;
}
match /groups/{groupId} {
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
@ -184,7 +184,7 @@ service cloud.firestore {
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {

@ -17,4 +17,5 @@ package-lock.json
ui-debug.log
firebase-debug.log
firestore-debug.log
pubsub-debug.log
firestore_export/

@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api'
const bodySchema = z.object({
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer
})
const contract = await getContract(contractId)
if (answer && contract) await sendNewAnswerEmail(answer, contract)
return answer
})

@ -10,7 +10,7 @@ import {
MAX_GROUP_NAME_LENGTH,
MAX_ID_LENGTH,
} from '../../common/group'
import { APIError, newEndpoint, validate } from '../../functions/src/api'
import { APIError, newEndpoint, validate } from './api'
import { z } from 'zod'
const bodySchema = z.object({

@ -1,13 +1,12 @@
import * as admin from 'firebase-admin'
import {
getDestinationsForUser,
Notification,
notification_reason_types,
notification_source_update_types,
notification_source_types,
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getValues, log } from './utils'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@ -15,20 +14,25 @@ import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Group } from '../../common/group'
import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
import { Like } from '../../common/like'
import {
sendMarketCloseEmail,
sendMarketResolutionEmail,
sendNewAnswerEmail,
sendNewCommentEmail,
} from './emails'
const firestore = admin.firestore()
type user_to_reason_texts = {
type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
}
export const createNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceType: 'contract' | 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
@ -41,9 +45,9 @@ export const createNotification = async (
) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldGetNotification = (
const shouldReceiveNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
userToReasonTexts: recipients_to_reason_texts
) => {
return (
sourceUser.id != userId &&
@ -51,18 +55,25 @@ export const createNotification = async (
)
}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
const sendNotificationsIfSettingsPermit = async (
userToReasonTexts: recipients_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
for (const userId in userToReasonTexts) {
const { reason } = userToReasonTexts[userId]
const privateUser = await getPrivateUser(userId)
if (!privateUser) continue
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
privateUser,
reason
)
if (sendToBrowser) {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
@ -80,12 +91,32 @@ export const createNotification = async (
sourceTitle: title ? title : sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
sourceUser,
privateUser,
sourceContract
)
} else if (reason === 'tagged_user') {
// TODO: send email to tagged user in new contract
} else if (reason === 'subsidized_your_market') {
// TODO: send email to creator of market that was subsidized
} else if (reason === 'contract_from_followed_user') {
// TODO: send email to follower of user who created market
} else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed
}
}
}
const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts
userToReasonTexts: recipients_to_reason_texts
) => {
const followers = await firestore
.collectionGroup('follows')
@ -96,72 +127,36 @@ export const createNotification = async (
const followerUserId = doc.ref.parent.parent?.id
if (
followerUserId &&
shouldGetNotification(followerUserId, userToReasonTexts)
shouldReceiveNotification(followerUserId, userToReasonTexts)
) {
userToReasonTexts[followerUserId] = {
reason: 'you_follow_user',
reason: 'contract_from_followed_user',
}
}
})
}
const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
) => {
if (shouldGetNotification(followedUserId, userToReasonTexts))
userToReasonTexts[followedUserId] = {
reason: 'on_new_follow',
}
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userToReasonTexts: recipients_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
if (id && shouldGetNotification(id, userToReasonTexts))
if (id && shouldReceiveNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract,
options?: { force: boolean }
) => {
if (
options?.force ||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
reason: 'added_you_to_group',
}
}
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
const userToReasonTexts: recipients_to_reason_texts = {}
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
if (shouldReceiveNotification(recipients[0], userToReasonTexts))
userToReasonTexts[recipients[0]] = {
reason: 'on_new_follow',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'created' &&
@ -169,123 +164,198 @@ export const createNotification = async (
) {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract)
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
}
await createUsersNotifications(userToReasonTexts)
}
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: 'created' | 'updated' | 'resolved',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedToType?: 'comment' | 'answer'
repliedToId?: string
repliedToContent?: string
repliedUserId?: string
taggedUserIds?: string[]
},
resolutionData?: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const {
repliedToType,
repliedToContent,
repliedUserId,
taggedUserIds,
repliedToId,
} = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
const recipientIdsList: string[] = []
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const createBrowserNotification = async (
userId: string,
reason: notification_reason_types
) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
const sendNotificationsIfSettingsPermit = async (
userId: string,
userToReasonTexts: user_to_reason_texts
reason: notification_reason_types
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
if (
!stillFollowingContract(sourceContract.creatorId) ||
sourceUser.id == userId ||
recipientIdsList.includes(userId)
)
return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
privateUser,
reason
)
if (sendToBrowser) {
await createBrowserNotification(userId, reason)
recipientIdsList.push(userId)
}
if (sendToEmail) {
if (sourceType === 'comment') {
// TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment
await sendNewCommentEmail(
reason,
privateUser,
sourceUser,
sourceContract,
sourceText,
sourceId,
// TODO: Add any paired bets to the comment
undefined,
repliedToType === 'answer' ? repliedToContent : undefined,
repliedToType === 'answer' ? repliedToId : undefined
)
} else if (sourceType === 'answer')
await sendNewAnswerEmail(
reason,
privateUser,
sourceUser.name,
sourceText,
sourceContract,
sourceUser.avatarUrl
)
else if (
sourceType === 'contract' &&
sourceUpdateType === 'resolved' &&
resolutionData
)
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
sourceUser,
resolutionData.creatorPayout,
sourceContract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
recipientIdsList.push(userId)
}
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyContractFollowers = async () => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
await sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_you_follow'
: sourceType === 'comment'
? 'comment_on_contract_you_follow'
: sourceUpdateType === 'updated'
? 'update_on_contract_you_follow'
: 'resolution_on_contract_you_follow'
)
}
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
const notifyContractCreator = async () => {
await sendNotificationsIfSettingsPermit(
sourceContract.creatorId,
sourceType === 'comment'
? 'comment_on_your_contract'
: 'answer_on_your_contract'
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyOtherAnswerersOnContract = async () => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
@ -293,20 +363,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_with_users_answer'
: sourceType === 'comment'
? 'comment_on_contract_with_users_answer'
: sourceUpdateType === 'updated'
? 'update_on_contract_with_users_answer'
: 'resolution_on_contract_with_users_answer'
)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
)
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyOtherCommentersOnContract = async () => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
@ -314,20 +387,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_with_users_comment'
: sourceType === 'comment'
? 'comment_on_contract_with_users_comment'
: sourceUpdateType === 'updated'
? 'update_on_contract_with_users_comment'
: 'resolution_on_contract_with_users_comment'
)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
)
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyBettorsOnContract = async () => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
@ -343,88 +419,73 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_with_users_shares_in'
: sourceType === 'comment'
? 'comment_on_contract_with_users_shares_in'
: sourceUpdateType === 'updated'
? 'update_on_contract_with_users_shares_in'
: 'resolution_on_contract_with_users_shares_in'
)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
)
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyRepliedUser = async () => {
if (sourceType === 'comment' && repliedUserId && repliedToType)
await sendNotificationsIfSettingsPermit(
repliedUserId,
repliedToType === 'answer'
? 'reply_to_users_answer'
: 'reply_to_users_comment'
)
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
const notifyTaggedUsers = async () => {
if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0)
await Promise.all(
taggedUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(userId, 'tagged_user')
)
)
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyLiquidityProviders = async () => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
await Promise.all(
liquidityProvidersIds.map((userId) =>
sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_with_users_shares_in'
: sourceType === 'comment'
? 'comment_on_contract_with_users_shares_in'
: sourceUpdateType === 'updated'
? 'update_on_contract_with_users_shares_in'
: 'resolution_on_contract_with_users_shares_in'
)
)
)
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts)
await notifyRepliedUser()
await notifyTaggedUsers()
await notifyContractCreator()
await notifyOtherAnswerersOnContract()
await notifyLiquidityProviders()
await notifyBettorsOnContract()
await notifyOtherCommentersOnContract()
// if they weren't notified previously, notify them now
await notifyContractFollowers()
}
export const createTipNotification = async (
@ -436,8 +497,15 @@ export const createTipNotification = async (
contract?: Contract,
group?: Group
) => {
const slug = group ? group.slug + `#${commentId}` : commentId
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'tip_received'
)
if (!sendToBrowser) return
const slug = group ? group.slug + `#${commentId}` : commentId
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -461,6 +529,9 @@ export const createTipNotification = async (
sourceTitle: group?.name,
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO: send notification to users that are watching the contract and want highly tipped comments only
// maybe TODO: send email notification to bet creator
}
export const createBetFillNotification = async (
@ -471,6 +542,14 @@ export const createBetFillNotification = async (
contract: Contract,
idempotencyKey: string
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'bet_fill'
)
if (!sendToBrowser) return
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0
@ -496,38 +575,8 @@ export const createBetFillNotification = async (
sourceContractId: contract.id,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createGroupCommentNotification = async (
fromUser: User,
toUserId: string,
comment: Comment,
group: Group,
idempotencyKey: string
) => {
if (toUserId === fromUser.id) return
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'on_group_you_are_member_of',
createdTime: Date.now(),
isSeen: false,
sourceId: comment.id,
sourceType: 'comment',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: richTextToString(comment.content),
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
}
await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to bet creator
}
export const createReferralNotification = async (
@ -538,6 +587,14 @@ export const createReferralNotification = async (
referredByContract?: Contract,
referredByGroup?: Group
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'you_referred_user'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -575,6 +632,8 @@ export const createReferralNotification = async (
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}
export const createLoanIncomeNotification = async (
@ -582,6 +641,14 @@ export const createLoanIncomeNotification = async (
idempotencyKey: string,
income: number
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'loan_income'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -612,6 +679,14 @@ export const createChallengeAcceptedNotification = async (
acceptedAmount: number,
contract: Contract
) => {
const privateUser = await getPrivateUser(challengeCreator.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'challenge_accepted'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
@ -645,6 +720,14 @@ export const createBettingStreakBonusNotification = async (
amount: number,
idempotencyKey: string
) => {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'betting_streak_incremented'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey)
@ -680,13 +763,24 @@ export const createLikeNotification = async (
contract: Contract,
tip?: TipTxn
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'liked_and_tipped_your_contract'
)
if (!sendToBrowser) return
// not handling just likes, must include tip
if (!tip) return
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
reason: 'liked_and_tipped_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: like.id,
@ -703,20 +797,8 @@ export const createLikeNotification = async (
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export async function filterUserIdsForOnlyFollowerIds(
userIds: string[],
contractId: string
) {
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${contractId}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
return userIds.filter((id) => contractFollowersIds.includes(id))
// TODO send email notification
}
export const createUniqueBettorBonusNotification = async (
@ -727,6 +809,15 @@ export const createUniqueBettorBonusNotification = async (
amount: number,
idempotencyKey: string
) => {
console.log('createUniqueBettorBonusNotification')
const privateUser = await getPrivateUser(contractCreatorId)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'unique_bettors_on_your_contract'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
@ -752,4 +843,6 @@ export const createUniqueBettorBonusNotification = async (
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}

@ -1,7 +1,11 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { PrivateUser, User } from '../../common/user'
import {
getDefaultNotificationSettings,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random'
import {
@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
email,
initialIpAddress: req.ip,
initialDeviceToken: deviceToken,
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
}
await firestore.collection('private-users').doc(auth.uid).create(privateUser)

@ -284,9 +284,12 @@
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>

@ -491,10 +491,10 @@
">
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a href="{{unsubscribeLink}}" style="
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe</a>.
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>

@ -440,11 +440,10 @@
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeLink}}"
style="
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe</a> from future recommended markets.
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>

@ -526,19 +526,10 @@
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

@ -367,14 +367,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

@ -485,14 +485,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

@ -367,14 +367,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

@ -500,14 +500,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

@ -1,519 +1,316 @@
<!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>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">
<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;
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
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;
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;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml> </noscript
>z
<![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>
<!--[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)">
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
</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;
}
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;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
</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%"
>
<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 align="center"
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 25px 0px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 0px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<a
href="https://manifold.markets/home"
target="_blank"
><img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.gif"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
</td>
</tr>
</tbody>
</table>
</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="
text-align: center;
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;
"
>Hopefully you haven&#39;t gambled all your M$
away already... but if you have I bring good
news! Click the link below to recieve a one time
gift of M$ 500 to your account!</span
>
</p>
</div>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px 25px 25px;
padding-top: 10px;
padding-right: 25px;
padding-bottom: 25px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<a href="{{manalink}}" target="_blank">
<img
alt="Get M$500"
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
<< /td>
</td>
</tr>
</tbody>
</table>
</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: 23px;
text-align: center;
margin: 10px 0;
margin-top: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Arial, Helvetica, sans-serif;
font-size: 18px;
"
>If you are still engaging with our markets then
at this point you might as well join our </span
><a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://discord.gg/VARzUpyCSa"
><span
style="
color: #0c21bf;
font-family: Arial;
font-size: 18px;
"
><u>Discord server</u></span
><span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
><u>.</u>
</span></a
><span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
>You can always leave if you dont like it but
I&#39;d be willing to make a market betting
you&#39;ll stay.</span
>
</p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
></p>
<br />
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
>
<span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
>Cheers,</span
>
</p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
>
<span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
>David from Manifold</span
>
</p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px"
></p>
</div>
</td>
</tr>
<tr>
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
width="550"></a></td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 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"
width="100%"
>
<tbody>
</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;">
Hi {{name}},</span></p>
</div>
</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;">Thanks for
using Manifold Markets. Running low
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<table cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 2px;" bgcolor="#4337c9">
<a href="{{manalink}}" target="_blank"
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
Claim M$500
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;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: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
you know, besides making correct predictions, there are
plenty of other ways to earn mana?</span></p>
<ul>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Predicting
consecutive days to earn streak rewards</span></li>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
tips on comments and markets</span></li>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Unique
predictor bonus for each user who predicts on your
markets</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
friends</u></span></a></span></li>
<li style="line-height:23px;"><a class="link-build-content"
style="color:inherit;; text-decoration: none;" target="_blank"
href="https://manifold.markets/group/bugs?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank"
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
feedback</u></span></a></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;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 align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 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" width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td
align="center"
style="
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
padding-top: 0px;
padding-bottom: 0px;
word-break: break-word;
"
>
<div
style="
font-family: Arial, sans-serif;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
letter-spacing: normal;
line-height: 22px;
text-align: center;
color: #000000;
"
>
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a
href="{{unsubscribeLink}}"
style="
">
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
"
target="_blank"
>click here to unsubscribe</a
>.
</p>
</div>
</td>
</tr>
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center"
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

@ -214,10 +214,12 @@
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent
to {{name}}, <a href="{{unsubscribeLink}}"
style="color:inherit;text-decoration:none;"
target="_blank">click here to
unsubscribe</a>.</p>
to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>

@ -137,7 +137,7 @@
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;">
Welcome! Manifold Markets is a play-money prediction market platform where you can bet on
Welcome! Manifold Markets is a play-money prediction market platform where you can predict
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
</div>
</td>
@ -286,9 +286,12 @@
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>

@ -1,10 +1,12 @@
import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import {
notification_subscription_types,
PrivateUser,
User,
} from '../../common/user'
import {
formatLargeNumber,
formatMoney,
@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse'
import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
import {
notification_reason_types,
getDestinationsForUser,
} from '../../common/notification'
export const sendMarketResolutionEmail = async (
userId: string,
reason: notification_reason_types,
privateUser: PrivateUser,
investment: number,
payout: number,
creator: User,
@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser || !privateUser.email || !sendToEmail) return
const user = await getUser(userId)
const user = await getUser(privateUser.id)
if (!user) return
const outcome = toDisplayResolution(
@ -53,13 +52,10 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText =
creatorPayout >= 1 && userId === creator.id
creatorPayout >= 1 && privateUser.id === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const displayedInvestment =
Number.isNaN(investment) || investment < 0
? formatMoney(0)
@ -154,11 +150,12 @@ export const sendWelcomeEmail = async (
) => {
if (!privateUser || !privateUser.email) return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
@ -166,7 +163,7 @@ export const sendWelcomeEmail = async (
'welcome',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
'Manifold Markets one week anniversary gift',
'one-week',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
manalink: 'https://manifold.markets/link/lj4JbBvE',
},
{
@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
'creating-market',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -282,15 +279,18 @@ export const sendThankYouEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes(
'email'
)
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'thank_you_for_purchases' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
@ -298,7 +298,7 @@ export const sendThankYouEmail = async (
'thank-you',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -307,16 +307,15 @@ export const sendThankYouEmail = async (
}
export const sendMarketCloseEmail = async (
reason: notification_reason_types,
user: User,
privateUser: PrivateUser,
contract: Contract
) => {
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async (
const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
return await sendTemplateEmail(
privateUser.email,
@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async (
}
export const sendNewCommentEmail = async (
userId: string,
reason: notification_reason_types,
privateUser: PrivateUser,
commentCreator: User,
contract: Contract,
comment: Comment,
commentText: string,
commentId: string,
bet?: Bet,
answerText?: string,
answerId?: string
) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromCommentEmails
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser || !privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const emailType = 'market-comment'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { question } = contract
const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { content } = comment
const text = richTextToString(content)
let betDescription = ''
if (bet) {
@ -380,7 +371,7 @@ export const sendNewCommentEmail = async (
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}`
const answerNumber = answerId ? `#${answerId}` : ''
return await sendTemplateEmail(
privateUser.email,
@ -391,7 +382,7 @@ export const sendNewCommentEmail = async (
answerNumber,
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -412,7 +403,7 @@ export const sendNewCommentEmail = async (
{
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -423,29 +414,24 @@ export const sendNewCommentEmail = async (
}
export const sendNewAnswerEmail = async (
answer: Answer,
contract: Contract
reason: notification_reason_types,
privateUser: PrivateUser,
name: string,
text: string,
contract: Contract,
avatarUrl?: string
) => {
// Send to just the creator for now.
const { creatorId: userId } = contract
const { creatorId } = contract
// Don't send the creator's own answers.
if (answer.userId === userId) return
if (privateUser.id === creatorId) return
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromAnswerEmails
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
const { name, avatarUrl, text } = answer
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
const emailType = 'market-answer'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>`
@ -474,12 +460,15 @@ export const sendInterestingMarketsEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser?.unsubscribedFromWeeklyTrendingEmails
!privateUser.notificationSubscriptionTypes.trending_markets.includes(
'email'
)
)
return
const emailType = 'weekly-trending'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'trending_markets' as keyof notification_subscription_types
}`
const { name } = user
const firstName = name.split(' ')[0]
@ -490,7 +479,7 @@ export const sendInterestingMarketsEmail = async (
'interesting-markets',
{
name: firstName,
unsubscribeLink: unsubscribeUrl,
unsubscribeUrl,
question1Title: contractsToSend[0].question,
question1Link: contractUrl(contractsToSend[0]),

@ -3,7 +3,6 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils'
import { sendMarketCloseEmail } from './emails'
import { createNotification } from './create-notification'
export const marketCloseNotifications = functions
@ -56,7 +55,6 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue
await sendMarketCloseEmail(user, privateUser, contract)
await createNotification(
contract.id,
'contract',

@ -1,15 +1,11 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { compact, uniq } from 'lodash'
import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import {
createCommentOrAnswerOrUpdatedContractNotification,
filterUserIdsForOnlyFollowerIds,
} from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions
const comments = await getValues<ContractComment>(
firestore.collection('contracts').doc(contractId).collection('comments')
)
const relatedSourceType = comment.replyToCommentId
? 'comment'
: comment.answerOutcome
const repliedToType = answer
? 'answer'
: comment.replyToCommentId
? 'comment'
: undefined
const repliedUserId = comment.replyToCommentId
@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions
richTextToString(comment.content),
contract,
{
relatedSourceType,
repliedToType,
repliedToId: comment.replyToCommentId || answer?.id,
repliedToContent: answer ? answer.text : undefined,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
}
)
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId),
contractId
)
await Promise.all(
recipientUserIds.map((userId) =>
sendNewCommentEmail(
userId,
commentCreator,
contract,
comment,
bet,
answer?.text,
answer?.id
)
)
)
})

@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
if (previousValue.isResolved !== contract.isResolved) {
let resolutionText = contract.resolution ?? contract.question
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = contract.answers.find(
(answer) => answer.id === contract.resolution
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && contract.resolutionProbability)
resolutionText = `${contract.resolutionProbability}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && contract.resolutionValue)
resolutionText = `${contract.resolutionValue}`
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
contractUpdater,
eventId,
resolutionText,
contract
)
} else if (
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
) {

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, mapValues, groupBy, sumBy } from 'lodash'
import { mapValues, groupBy, sumBy } from 'lodash'
import {
Contract,
@ -8,10 +8,8 @@ import {
MultipleChoiceContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
import {
getLoanPayouts,
getPayouts,
@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
import { getContractBetMetrics } from '../../common/calculate'
import { floatingEqual } from '../../common/util/math'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
@ -163,15 +161,45 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
bets,
userPayoutsWithoutLoans,
const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested
)
let resolutionText = outcome ?? contract.question
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
}
// TODO: this actually may be too slow to complete with a ton of users to notify?
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
creator,
creatorPayout,
contract.id + '-resolution',
resolutionText,
contract,
outcome,
resolutionProbability,
resolutions
undefined,
{
bets,
userInvestments,
userPayouts: userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions,
}
)
return updatedContract
@ -189,51 +217,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
.then(() => ({ status: 'success' }))
}
const sendResolutionEmails = async (
bets: Bet[],
userPayouts: { [userId: string]: number },
creator: User,
creatorPayout: number,
contract: Contract,
outcome: string,
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const investedByUser = mapValues(
groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested
)
const investedUsers = Object.keys(investedByUser).filter(
(userId) => !floatingEqual(investedByUser[userId], 0)
)
const nonWinners = difference(investedUsers, Object.keys(userPayouts))
const emailPayouts = [
...Object.entries(userPayouts),
...nonWinners.map((userId) => [userId, 0] as const),
].map(([userId, payout]) => ({
userId,
investment: investedByUser[userId] ?? 0,
payout,
}))
await Promise.all(
emailPayouts.map(({ userId, investment, payout }) =>
sendMarketResolutionEmail(
userId,
investment,
payout,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions
)
)
)
}
function getResolutionParams(contract: Contract, body: string) {
const { outcomeType } = contract

@ -0,0 +1,30 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getDefaultNotificationSettings } from 'common/user'
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
const disableEmails = !isProd()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationSubscriptionTypes: getDefaultNotificationSettings(
privateUser.id,
privateUser,
disableEmails
),
})
})
)
}
if (require.main === module) main().then(() => process.exit())

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { PrivateUser, User } from 'common/user'
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy'
const firestore = admin.firestore()
@ -21,6 +21,7 @@ async function main() {
id: user.id,
email,
username,
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id),
}
if (user.totalDeposits === undefined) {

@ -1,236 +0,0 @@
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import toast from 'react-hot-toast'
import { track } from '@amplitude/analytics-browser'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { Col } from 'web/components/layout/col'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
export function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string | React.ReactNode
highlight: boolean
onClick?: () => void
}) {
const { label, highlight, onClick } = props
return (
<Row
className={clsx(
'my-1 gap-1 text-gray-300',
highlight && '!text-black',
onClick ? 'cursor-pointer' : ''
)}
onClick={onClick}
>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<Col className={''}>
<Row className={'my-1'}>
You will receive notifications for these general events:
</Row>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<Row className={'my-1'}>
You will receive new comment, answer, & resolution notifications on
questions:
</Row>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={
<span>
That <span className={'font-bold'}>you watch </span>- you
auto-watch questions if:
</span>
}
onClick={() => setShowModal(true)}
/>
<Col
className={clsx(
'mb-2 ml-8',
'gap-1 text-gray-300',
notificationSettings !== 'none' && '!text-black'
)}
>
<Row> You create it</Row>
<Row> You bet, comment on, or answer it</Row>
<Row> You add liquidity to it</Row>
<Row>
If you select 'Less' and you've commented on or answered a
question, you'll only receive notification on direct replies to
your comments or answers
</Row>
</Col>
</Col>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
<FollowMarketModal setOpen={setShowModal} open={showModal} />
</div>
)
}

@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
export const FollowMarketModal = (props: {
export const WatchMarketModal = (props: {
open: boolean
setOpen: (b: boolean) => void
title?: string
@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}>
You can receive notifications on questions you're interested in by
You'll receive notifications on markets by betting, commenting, or
clicking the
<EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true"
/>
button on a question.
button on them.
</span>
<span className={'text-indigo-700'}>
What types of notifications will I receive?
</span>
<span className={'ml-2'}>
You'll receive in-app notifications for new comments, answers, and
updates to the question.
You'll receive notifications for new comments, answers, and updates
to the question. See the notifications settings pages to customize
which types of notifications you receive on watched markets.
</span>
</Col>
</Col>

@ -11,7 +11,7 @@ import { User } from 'common/user'
import { useContractFollows } from 'web/hooks/use-follows'
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { useState } from 'react'
import { Col } from 'web/components/layout/col'
@ -65,7 +65,7 @@ export const FollowMarketButton = (props: {
Watch
</Col>
)}
<FollowMarketModal
<WatchMarketModal
open={open}
setOpen={setOpen}
title={`You ${

@ -0,0 +1,320 @@
import { usePrivateUser } from 'web/hooks/use-user'
import React, { ReactNode, useEffect, useState } from 'react'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import {
notification_subscription_types,
notification_destination_types,
} from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import {
CashIcon,
ChatIcon,
ChevronDownIcon,
ChevronUpIcon,
CurrencyDollarIcon,
InboxInIcon,
InformationCircleIcon,
LightBulbIcon,
TrendingUpIcon,
UserIcon,
UsersIcon,
} from '@heroicons/react/outline'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { filterDefined } from 'common/util/array'
import toast from 'react-hot-toast'
import { SwitchSetting } from 'web/components/switch-setting'
export function NotificationSettings(props: {
navigateToSection: string | undefined
}) {
const { navigateToSection } = props
const privateUser = usePrivateUser()
const [showWatchModal, setShowWatchModal] = useState(false)
if (!privateUser || !privateUser.notificationSubscriptionTypes) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
const emailsEnabled: Array<keyof notification_subscription_types> = [
'all_comments_on_watched_markets',
'all_replies_to_my_comments_on_watched_markets',
'all_comments_on_contracts_with_shares_in_on_watched_markets',
'all_answers_on_watched_markets',
'all_replies_to_my_answers_on_watched_markets',
'all_answers_on_contracts_with_shares_in_on_watched_markets',
'your_contract_closed',
'all_comments_on_my_markets',
'all_answers_on_my_markets',
'resolutions_on_watched_markets_with_shares_in',
'resolutions_on_watched_markets',
'tagged_user',
'trending_markets',
'onboarding_flow',
'thank_you_for_purchases',
// TODO: add these
// 'contract_from_followed_user',
// 'referral_bonuses',
// 'unique_bettors_on_your_contract',
// 'tips_on_your_markets',
// 'tips_on_your_comments',
// 'subsidized_your_market',
// 'on_new_follow',
// maybe the following?
// 'profit_loss_updates',
// 'probability_updates_on_watched_markets',
// 'limit_order_fills',
]
const browserDisabled: Array<keyof notification_subscription_types> = [
'trending_markets',
'profit_loss_updates',
'onboarding_flow',
'thank_you_for_purchases',
]
type sectionData = {
label: string
subscriptionTypeToDescription: {
[key in keyof Partial<notification_subscription_types>]: string
}
}
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`,
all_replies_to_my_comments_on_watched_markets:
'Only replies to your comments',
// comments_by_followed_users_on_watched_markets: 'By followed users',
},
}
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`,
all_replies_to_my_answers_on_watched_markets:
'Only replies to your answers',
// answers_by_followed_users_on_watched_markets: 'By followed users',
// answers_by_market_creator_on_watched_markets: 'By market creator',
},
}
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',
},
}
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',
},
}
const bonuses: sectionData = {
label: 'Bonuses',
subscriptionTypeToDescription: {
betting_streaks: 'Betting streak bonuses',
referral_bonuses: 'Referral bonuses from referring users',
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
},
}
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',
},
}
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',
},
}
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',
},
}
const NotificationSettingLine = (
description: string,
key: keyof notification_subscription_types,
value: notification_destination_types[]
) => {
const previousInAppValue = value.includes('browser')
const previousEmailValue = value.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!'
const highlight = navigateToSection === key
useEffect(() => {
if (
inAppEnabled !== previousInAppValue ||
emailEnabled !== previousEmailValue
) {
toast.promise(
updatePrivateUser(privateUser.id, {
notificationSubscriptionTypes: {
...privateUser.notificationSubscriptionTypes,
[key]: filterDefined([
inAppEnabled ? 'browser' : undefined,
emailEnabled ? 'email' : undefined,
]),
},
}),
{
success,
loading,
error: 'Error changing notification settings. Try again?',
}
)
}
}, [
inAppEnabled,
emailEnabled,
previousInAppValue,
previousEmailValue,
key,
])
return (
<Row
className={clsx(
'my-1 gap-1 text-gray-300',
highlight ? 'rounded-md bg-indigo-100 p-1' : ''
)}
>
<Col className="ml-3 gap-2 text-sm">
<Row className="gap-2 font-medium text-gray-700">
<span>{description}</span>
</Row>
<Row className={'gap-4'}>
{!browserDisabled.includes(key) && (
<SwitchSetting
checked={inAppEnabled}
onChange={setInAppEnabled}
label={'In App'}
/>
)}
{emailsEnabled.includes(key) && (
<SwitchSetting
checked={emailEnabled}
onChange={setEmailEnabled}
label={'Emails'}
/>
)}
</Row>
</Col>
</Row>
)
}
const getUsersSavedPreference = (
key: keyof notification_subscription_types
) => {
return privateUser.notificationSubscriptionTypes[key] ?? []
}
const Section = (icon: ReactNode, data: sectionData) => {
const { label, subscriptionTypeToDescription } = data
const expand =
navigateToSection &&
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
const [expanded, setExpanded] = useState(expand)
// Not working as the default value for expanded, so using a useEffect
useEffect(() => {
if (expand) setExpanded(true)
}, [expand])
return (
<Col className={clsx('ml-2 gap-2')}>
<Row
className={'mt-1 cursor-pointer items-center gap-2 text-gray-600'}
onClick={() => setExpanded(!expanded)}
>
{icon}
<span>{label}</span>
{expanded ? (
<ChevronUpIcon className="h-5 w-5 text-xs text-gray-500">
Hide
</ChevronUpIcon>
) : (
<ChevronDownIcon className="h-5 w-5 text-xs text-gray-500">
Show
</ChevronDownIcon>
)}
</Row>
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{Object.entries(subscriptionTypeToDescription).map(([key, value]) =>
NotificationSettingLine(
value,
key as keyof notification_subscription_types,
getUsersSavedPreference(
key as keyof notification_subscription_types
)
)
)}
</Col>
</Col>
)
}
return (
<div className={'p-2'}>
<Col className={'gap-6'}>
<Row className={'gap-2 text-xl text-gray-700'}>
<span>Notifications for Watched Markets</span>
<InformationCircleIcon
className="-mb-1 h-5 w-5 cursor-pointer text-gray-500"
onClick={() => setShowWatchModal(true)}
/>
</Row>
{Section(<ChatIcon className={'h-6 w-6'} />, comments)}
{Section(<LightBulbIcon className={'h-6 w-6'} />, answers)}
{Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)}
{Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)}
<Row className={'gap-2 text-xl text-gray-700'}>
<span>Balance Changes</span>
</Row>
{Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)}
{Section(<CashIcon className={'h-6 w-6'} />, otherBalances)}
<Row className={'gap-2 text-xl text-gray-700'}>
<span>General</span>
</Row>
{Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)}
{Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)}
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col>
</div>
)
}

@ -0,0 +1,34 @@
import { Switch } from '@headlessui/react'
import clsx from 'clsx'
import React from 'react'
export const SwitchSetting = (props: {
checked: boolean
onChange: (checked: boolean) => void
label: string
}) => {
const { checked, onChange, label } = props
return (
<Switch.Group as="div" className="flex items-center">
<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'
)}
>
<span
aria-hidden="true"
className={clsx(
checked ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm font-medium text-gray-900">{label}</span>
</Switch.Label>
</Switch.Group>
)
}

@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { PrivateUser } from 'common/user'
import { Notification } from 'common/notification'
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
import { groupBy, map, partition } from 'lodash'
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
if (!result.data) return undefined
const notifications = result.data as Notification[]
return getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref)
}, [privateUser.notificationPreferences, result.data])
return notifications.filter((n) => !n.isSeenOnHref)
}, [result.data])
return notifications
}
@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) {
})
return notificationGroups
}
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in',
]
function getAppropriateNotifications(
notifications: Notification[],
notificationPreferences?: notification_subscribe_types
) {
if (notificationPreferences === 'all') return notifications
if (notificationPreferences === 'less')
return notifications.filter(
(n) =>
n.reason &&
// Show all contract notifications and any that aren't in the above list:
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
)
if (notificationPreferences === 'none') return []
return notifications
}

@ -1,6 +1,6 @@
import { Tabs } from 'web/components/layout/tabs'
import { ControlledTabs } from 'web/components/layout/tabs'
import React, { useEffect, useMemo, useState } from 'react'
import Router from 'next/router'
import Router, { useRouter } from 'next/router'
import { Notification, notification_source_types } from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
@ -40,7 +40,7 @@ import { Pagination } from 'web/components/pagination'
import { useWindowSize } from 'web/hooks/use-window-size'
import { safeLocalStorage } from 'web/lib/util/local'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
import { NotificationSettings } from 'web/components/notification-settings'
import { SEO } from 'web/components/SEO'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { UserLink } from 'web/components/user-link'
@ -56,24 +56,49 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
export default function Notifications() {
const privateUser = usePrivateUser()
const router = useRouter()
const [navigateToSection, setNavigateToSection] = useState<string>()
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
if (privateUser === null) Router.push('/')
})
useEffect(() => {
const query = { ...router.query }
if (query.section) {
setNavigateToSection(query.section as string)
setActiveIndex(1)
}
}, [router.query])
return (
<Page>
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
<Title text={'Notifications'} className={'hidden md:block'} />
<SEO title="Notifications" description="Manifold user notifications" />
{privateUser && (
{privateUser && router.isReady && (
<div>
<Tabs
<ControlledTabs
currentPageForAnalytics={'notifications'}
labelClassName={'pb-2 pt-1 '}
className={'mb-0 sm:mb-2'}
defaultIndex={0}
activeIndex={activeIndex}
onClick={(title, i) => {
router.replace(
{
query: {
...router.query,
tab: title.toLowerCase(),
section: '',
},
},
undefined,
{ shallow: true }
)
setActiveIndex(i)
}}
tabs={[
{
title: 'Notifications',
@ -82,9 +107,9 @@ export default function Notifications() {
{
title: 'Settings',
content: (
<div className={''}>
<NotificationSettings />
</div>
<NotificationSettings
navigateToSection={navigateToSection}
/>
),
},
]}
@ -992,6 +1017,7 @@ function getReasonForShowingNotification(
) {
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
let reasonText: string
// TODO: we could leave out this switch and just use the reason field now that they have more information
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
@ -1003,7 +1029,7 @@ function getReasonForShowingNotification(
else reasonText = justSummary ? `commented` : `commented on`
break
case 'contract':
if (reason === 'you_follow_user')
if (reason === 'contract_from_followed_user')
reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved`
@ -1011,7 +1037,8 @@ function getReasonForShowingNotification(
else reasonText = justSummary ? 'updated the question' : `updated`
break
case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question `
if (reason === 'answer_on_your_contract')
reasonText = `answered your question `
else reasonText = `answered`
break
case 'follow':

Loading…
Cancel
Save