[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
This commit is contained in:
parent
28f0c6b1f8
commit
5c6328ffc2
|
@ -1,3 +1,6 @@
|
||||||
|
import { notification_subscription_types, PrivateUser } from './user'
|
||||||
|
import { DOMAIN } from './envs/constants'
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -51,28 +54,106 @@ export type notification_source_update_types =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
|
|
||||||
|
/* Optional - if possible use a keyof notification_subscription_types */
|
||||||
export type notification_reason_types =
|
export type notification_reason_types =
|
||||||
| 'tagged_user'
|
| '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'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'contract_from_followed_user'
|
||||||
| 'added_you_to_group'
|
|
||||||
| 'you_referred_user'
|
| 'you_referred_user'
|
||||||
| 'user_joined_to_bet_on_your_market'
|
| 'user_joined_to_bet_on_your_market'
|
||||||
| 'unique_bettors_on_your_contract'
|
| 'unique_bettors_on_your_contract'
|
||||||
| 'on_group_you_are_member_of'
|
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
| 'you_follow_contract'
|
|
||||||
| 'liked_your_contract'
|
|
||||||
| 'liked_and_tipped_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§ion=${subscriptionType}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
190
common/user.ts
190
common/user.ts
|
@ -1,3 +1,5 @@
|
||||||
|
import { filterDefined } from './util/array'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -63,9 +65,60 @@ export type PrivateUser = {
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
/** @deprecated - use notificationSubscriptionTypes */
|
||||||
notificationPreferences?: notification_subscribe_types
|
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 notification_subscribe_types = 'all' | 'less' | 'none'
|
||||||
|
|
||||||
export type PortfolioMetrics = {
|
export type PortfolioMetrics = {
|
||||||
|
@ -78,3 +131,140 @@ export type PortfolioMetrics = {
|
||||||
|
|
||||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
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": {
|
"functions": {
|
||||||
"predeploy": "cd functions && yarn build",
|
"predeploy": "cd functions && yarn build",
|
||||||
"runtime": "nodejs16",
|
"runtime": "nodejs16",
|
||||||
"source": "functions/dist"
|
"source": "functions/dist",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"firebase-debug.log",
|
||||||
|
"firebase-debug.*.log"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"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 read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (userId == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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} {
|
match /private-users/{userId}/views/{viewId} {
|
||||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -17,4 +17,5 @@ package-lock.json
|
||||||
ui-debug.log
|
ui-debug.log
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
firestore-debug.log
|
firestore-debug.log
|
||||||
|
pubsub-debug.log
|
||||||
firestore_export/
|
firestore_export/
|
||||||
|
|
|
@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getNewMultiBetInfo } from '../../common/new-bet'
|
import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||||
import { getContract, getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { sendNewAnswerEmail } from './emails'
|
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
const contract = await getContract(contractId)
|
|
||||||
|
|
||||||
if (answer && contract) await sendNewAnswerEmail(answer, contract)
|
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
MAX_GROUP_NAME_LENGTH,
|
MAX_GROUP_NAME_LENGTH,
|
||||||
MAX_ID_LENGTH,
|
MAX_ID_LENGTH,
|
||||||
} from '../../common/group'
|
} from '../../common/group'
|
||||||
import { APIError, newEndpoint, validate } from '../../functions/src/api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {
|
import {
|
||||||
|
getDestinationsForUser,
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
notification_source_update_types,
|
|
||||||
notification_source_types,
|
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getValues, log } from './utils'
|
import { getPrivateUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -15,20 +14,25 @@ import { Answer } from '../../common/answer'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { Challenge } from '../../common/challenge'
|
import { Challenge } from '../../common/challenge'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
|
||||||
import { Like } from '../../common/like'
|
import { Like } from '../../common/like'
|
||||||
|
import {
|
||||||
|
sendMarketCloseEmail,
|
||||||
|
sendMarketResolutionEmail,
|
||||||
|
sendNewAnswerEmail,
|
||||||
|
sendNewCommentEmail,
|
||||||
|
} from './emails'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type recipients_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types }
|
[userId: string]: { reason: notification_reason_types }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: notification_source_types,
|
sourceType: 'contract' | 'liquidity' | 'follow',
|
||||||
sourceUpdateType: notification_source_update_types,
|
sourceUpdateType: 'closed' | 'created',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
|
@ -41,9 +45,9 @@ export const createNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldReceiveNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: recipients_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
sourceUser.id != userId &&
|
sourceUser.id != userId &&
|
||||||
|
@ -51,18 +55,25 @@ export const createNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createUsersNotifications = async (
|
const sendNotificationsIfSettingsPermit = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: recipients_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
await Promise.all(
|
for (const userId in userToReasonTexts) {
|
||||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
const { reason } = userToReasonTexts[userId]
|
||||||
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
if (!privateUser) continue
|
||||||
|
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
if (sendToBrowser) {
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${userId}/notifications`)
|
.collection(`/users/${userId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId,
|
userId,
|
||||||
reason: userToReasonTexts[userId].reason,
|
reason,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
@ -80,12 +91,32 @@ export const createNotification = async (
|
||||||
sourceTitle: title ? title : sourceContract?.question,
|
sourceTitle: title ? title : sourceContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
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 (
|
const notifyUsersFollowers = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: recipients_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
const followers = await firestore
|
const followers = await firestore
|
||||||
.collectionGroup('follows')
|
.collectionGroup('follows')
|
||||||
|
@ -96,72 +127,36 @@ export const createNotification = async (
|
||||||
const followerUserId = doc.ref.parent.parent?.id
|
const followerUserId = doc.ref.parent.parent?.id
|
||||||
if (
|
if (
|
||||||
followerUserId &&
|
followerUserId &&
|
||||||
shouldGetNotification(followerUserId, userToReasonTexts)
|
shouldReceiveNotification(followerUserId, userToReasonTexts)
|
||||||
) {
|
) {
|
||||||
userToReasonTexts[followerUserId] = {
|
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 = (
|
const notifyTaggedUsers = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: recipients_to_reason_texts,
|
||||||
userIds: (string | undefined)[]
|
userIds: (string | undefined)[]
|
||||||
) => {
|
) => {
|
||||||
userIds.forEach((id) => {
|
userIds.forEach((id) => {
|
||||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
if (id && shouldReceiveNotification(id, userToReasonTexts))
|
||||||
userToReasonTexts[id] = {
|
userToReasonTexts[id] = {
|
||||||
reason: 'tagged_user',
|
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.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
const userToReasonTexts: recipients_to_reason_texts = {}
|
||||||
|
|
||||||
if (sourceType === 'follow' && recipients?.[0]) {
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
if (shouldReceiveNotification(recipients[0], userToReasonTexts))
|
||||||
} else if (
|
userToReasonTexts[recipients[0]] = {
|
||||||
sourceType === 'group' &&
|
reason: 'on_new_follow',
|
||||||
sourceUpdateType === 'created' &&
|
}
|
||||||
recipients
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
) {
|
|
||||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
|
||||||
} else if (
|
} else if (
|
||||||
sourceType === 'contract' &&
|
sourceType === 'contract' &&
|
||||||
sourceUpdateType === 'created' &&
|
sourceUpdateType === 'created' &&
|
||||||
|
@ -169,53 +164,84 @@ export const createNotification = async (
|
||||||
) {
|
) {
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
} else if (
|
} else if (
|
||||||
sourceType === 'contract' &&
|
sourceType === 'contract' &&
|
||||||
sourceUpdateType === 'closed' &&
|
sourceUpdateType === 'closed' &&
|
||||||
sourceContract
|
sourceContract
|
||||||
) {
|
) {
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
force: true,
|
reason: 'your_contract_closed',
|
||||||
})
|
}
|
||||||
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
} else if (
|
} else if (
|
||||||
sourceType === 'liquidity' &&
|
sourceType === 'liquidity' &&
|
||||||
sourceUpdateType === 'created' &&
|
sourceUpdateType === 'created' &&
|
||||||
sourceContract
|
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 (
|
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: notification_source_types,
|
sourceType: 'comment' | 'answer' | 'contract',
|
||||||
sourceUpdateType: notification_source_update_types,
|
sourceUpdateType: 'created' | 'updated' | 'resolved',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
sourceContract: Contract,
|
sourceContract: Contract,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
relatedSourceType?: notification_source_types
|
repliedToType?: 'comment' | 'answer'
|
||||||
|
repliedToId?: string
|
||||||
|
repliedToContent?: string
|
||||||
repliedUserId?: string
|
repliedUserId?: string
|
||||||
taggedUserIds?: 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 (
|
const recipientIdsList: string[] = []
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
|
const contractFollowersSnap = await firestore
|
||||||
|
.collection(`contracts/${sourceContract.id}/follows`)
|
||||||
|
.get()
|
||||||
|
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||||
|
(doc) => doc.data().id
|
||||||
|
)
|
||||||
|
|
||||||
|
const createBrowserNotification = async (
|
||||||
|
userId: string,
|
||||||
|
reason: notification_reason_types
|
||||||
) => {
|
) => {
|
||||||
await Promise.all(
|
|
||||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${userId}/notifications`)
|
.collection(`/users/${userId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId,
|
userId,
|
||||||
reason: userToReasonTexts[userId].reason,
|
reason,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
@ -232,60 +258,104 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
sourceSlug: sourceContract.slug,
|
sourceSlug: sourceContract.slug,
|
||||||
sourceTitle: sourceContract.question,
|
sourceTitle: sourceContract.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 stillFollowingContract = (userId: string) => {
|
const stillFollowingContract = (userId: string) => {
|
||||||
return contractFollowersIds.includes(userId)
|
return contractFollowersIds.includes(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const sendNotificationsIfSettingsPermit = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
reason: notification_reason_types
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
sourceUser.id != userId &&
|
|
||||||
!Object.keys(userToReasonTexts).includes(userId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractFollowers = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
for (const userId of contractFollowersIds) {
|
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'you_follow_contract',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreator = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
!stillFollowingContract(sourceContract.creatorId) ||
|
||||||
stillFollowingContract(sourceContract.creatorId)
|
sourceUser.id == userId ||
|
||||||
|
recipientIdsList.includes(userId)
|
||||||
)
|
)
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
return
|
||||||
reason: 'on_users_contract',
|
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 notifyOtherAnswerersOnContract = async (
|
const notifyContractFollowers = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
for (const userId of contractFollowersIds) {
|
||||||
) => {
|
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 () => {
|
||||||
|
await sendNotificationsIfSettingsPermit(
|
||||||
|
sourceContract.creatorId,
|
||||||
|
sourceType === 'comment'
|
||||||
|
? 'comment_on_your_contract'
|
||||||
|
: 'answer_on_your_contract'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherAnswerersOnContract = async () => {
|
||||||
const answers = await getValues<Answer>(
|
const answers = await getValues<Answer>(
|
||||||
firestore
|
firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -293,20 +363,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
.collection('answers')
|
.collection('answers')
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
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 (
|
const notifyOtherCommentersOnContract = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<Comment>(
|
||||||
firestore
|
firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -314,20 +387,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
.collection('comments')
|
.collection('comments')
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
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 (
|
const notifyBettorsOnContract = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const betsSnap = await firestore
|
const betsSnap = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/bets`)
|
.collection(`contracts/${sourceContract.id}/bets`)
|
||||||
.get()
|
.get()
|
||||||
|
@ -343,88 +419,73 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
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 = (
|
const notifyRepliedUser = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts,
|
if (sourceType === 'comment' && repliedUserId && repliedToType)
|
||||||
relatedUserId: string,
|
await sendNotificationsIfSettingsPermit(
|
||||||
relatedSourceType: notification_source_types
|
repliedUserId,
|
||||||
) => {
|
repliedToType === 'answer'
|
||||||
if (
|
? 'reply_to_users_answer'
|
||||||
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
: 'reply_to_users_comment'
|
||||||
stillFollowingContract(relatedUserId)
|
)
|
||||||
) {
|
|
||||||
if (relatedSourceType === 'comment') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_comment',
|
|
||||||
}
|
|
||||||
} else if (relatedSourceType === 'answer') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_answer',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyTaggedUsers = (
|
const notifyTaggedUsers = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts,
|
if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0)
|
||||||
userIds: (string | undefined)[]
|
await Promise.all(
|
||||||
) => {
|
taggedUserIds.map((userId) =>
|
||||||
userIds.forEach((id) => {
|
sendNotificationsIfSettingsPermit(userId, 'tagged_user')
|
||||||
console.log('tagged user: ', id)
|
)
|
||||||
// Allowing non-following users to get tagged
|
)
|
||||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
|
||||||
userToReasonTexts[id] = {
|
|
||||||
reason: 'tagged_user',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyLiquidityProviders = async (
|
const notifyLiquidityProviders = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const liquidityProviders = await firestore
|
const liquidityProviders = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/liquidity`)
|
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||||
.get()
|
.get()
|
||||||
const liquidityProvidersIds = uniq(
|
const liquidityProvidersIds = uniq(
|
||||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||||
)
|
)
|
||||||
liquidityProvidersIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
liquidityProvidersIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
userId,
|
||||||
) {
|
sourceType === 'answer'
|
||||||
userToReasonTexts[userId] = {
|
? 'answer_on_contract_with_users_shares_in'
|
||||||
reason: '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') {
|
await notifyRepliedUser()
|
||||||
if (repliedUserId && relatedSourceType)
|
await notifyTaggedUsers()
|
||||||
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
await notifyContractCreator()
|
||||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
await notifyOtherAnswerersOnContract()
|
||||||
}
|
await notifyLiquidityProviders()
|
||||||
await notifyContractCreator(userToReasonTexts)
|
await notifyBettorsOnContract()
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
await notifyOtherCommentersOnContract()
|
||||||
await notifyLiquidityProviders(userToReasonTexts)
|
// if they weren't notified previously, notify them now
|
||||||
await notifyBettorsOnContract(userToReasonTexts)
|
await notifyContractFollowers()
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts)
|
|
||||||
// if they weren't added previously, add them now
|
|
||||||
await notifyContractFollowers(userToReasonTexts)
|
|
||||||
|
|
||||||
await createUsersNotifications(userToReasonTexts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTipNotification = async (
|
export const createTipNotification = async (
|
||||||
|
@ -436,8 +497,15 @@ export const createTipNotification = async (
|
||||||
contract?: Contract,
|
contract?: Contract,
|
||||||
group?: Group
|
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
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -461,6 +529,9 @@ export const createTipNotification = async (
|
||||||
sourceTitle: group?.name,
|
sourceTitle: group?.name,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
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 (
|
export const createBetFillNotification = async (
|
||||||
|
@ -471,6 +542,14 @@ export const createBetFillNotification = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
idempotencyKey: string
|
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 fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
|
||||||
const fillAmount = fill?.amount ?? 0
|
const fillAmount = fill?.amount ?? 0
|
||||||
|
|
||||||
|
@ -496,38 +575,8 @@ export const createBetFillNotification = async (
|
||||||
sourceContractId: contract.id,
|
sourceContractId: contract.id,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
|
||||||
|
|
||||||
export const createGroupCommentNotification = async (
|
// maybe TODO: send email notification to bet creator
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createReferralNotification = async (
|
export const createReferralNotification = async (
|
||||||
|
@ -538,6 +587,14 @@ export const createReferralNotification = async (
|
||||||
referredByContract?: Contract,
|
referredByContract?: Contract,
|
||||||
referredByGroup?: Group
|
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
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -575,6 +632,8 @@ export const createReferralNotification = async (
|
||||||
: referredByContract?.question,
|
: referredByContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO send email notification
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createLoanIncomeNotification = async (
|
export const createLoanIncomeNotification = async (
|
||||||
|
@ -582,6 +641,14 @@ export const createLoanIncomeNotification = async (
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
income: number
|
income: number
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'loan_income'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -612,6 +679,14 @@ export const createChallengeAcceptedNotification = async (
|
||||||
acceptedAmount: number,
|
acceptedAmount: number,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(challengeCreator.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'challenge_accepted'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${challengeCreator.id}/notifications`)
|
.collection(`/users/${challengeCreator.id}/notifications`)
|
||||||
.doc()
|
.doc()
|
||||||
|
@ -645,6 +720,14 @@ export const createBettingStreakBonusNotification = async (
|
||||||
amount: number,
|
amount: number,
|
||||||
idempotencyKey: string
|
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
|
const notificationRef = firestore
|
||||||
.collection(`/users/${user.id}/notifications`)
|
.collection(`/users/${user.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -680,13 +763,24 @@ export const createLikeNotification = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
tip?: TipTxn
|
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
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId: toUser.id,
|
userId: toUser.id,
|
||||||
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
|
reason: 'liked_and_tipped_your_contract',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: like.id,
|
sourceId: like.id,
|
||||||
|
@ -703,20 +797,8 @@ export const createLikeNotification = async (
|
||||||
sourceTitle: contract.question,
|
sourceTitle: contract.question,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
|
||||||
|
|
||||||
export async function filterUserIdsForOnlyFollowerIds(
|
// TODO send email notification
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createUniqueBettorBonusNotification = async (
|
export const createUniqueBettorBonusNotification = async (
|
||||||
|
@ -727,6 +809,15 @@ export const createUniqueBettorBonusNotification = async (
|
||||||
amount: number,
|
amount: number,
|
||||||
idempotencyKey: string
|
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
|
const notificationRef = firestore
|
||||||
.collection(`/users/${contractCreatorId}/notifications`)
|
.collection(`/users/${contractCreatorId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -752,4 +843,6 @@ export const createUniqueBettorBonusNotification = async (
|
||||||
sourceContractCreatorUsername: contract.creatorUsername,
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO send email notification
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
getDefaultNotificationSettings,
|
||||||
|
PrivateUser,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import { getUser, getUserByUsername, getValues } from './utils'
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import {
|
import {
|
||||||
|
@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
email,
|
email,
|
||||||
initialIpAddress: req.ip,
|
initialIpAddress: req.ip,
|
||||||
initialDeviceToken: deviceToken,
|
initialDeviceToken: deviceToken,
|
||||||
|
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
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;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -491,10 +491,10 @@
|
||||||
">
|
">
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a href="{{unsubscribeLink}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a>.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -440,11 +440,10 @@
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to
|
This e-mail has been sent to
|
||||||
{{name}},
|
{{name}},
|
||||||
<a href="{{unsubscribeLink}}"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -526,19 +526,10 @@
|
||||||
"
|
"
|
||||||
>our Discord</a
|
>our Discord</a
|
||||||
>! Or,
|
>! Or,
|
||||||
<a
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
href="{{unsubscribeUrl}}"
|
color: inherit;
|
||||||
style="
|
text-decoration: none;
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
sans-serif;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
"
|
|
||||||
>unsubscribe</a
|
|
||||||
>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -485,14 +485,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -500,14 +500,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<title>7th Day Anniversary Gift!</title>
|
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
#outlook a {
|
#outlook a {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -51,14 +49,12 @@
|
||||||
<o:AllowPNG/>
|
<o:AllowPNG/>
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
</o:OfficeDocumentSettings>
|
</o:OfficeDocumentSettings>
|
||||||
</xml> </noscript
|
</xml>
|
||||||
>z
|
</noscript>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if lte mso 11]>
|
<!--[if lte mso 11]>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.mj-outlook-group-fix {
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
@ -94,314 +90,135 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||||
<div style="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]-->
|
<!--[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
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
style="
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
background: #ffffff;
|
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="
|
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;">
|
||||||
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
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%;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
font-size: 0px;
|
width="100%">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center"
|
||||||
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;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
font-size: 0px;
|
style="border-collapse:collapse;border-spacing: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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 550px">
|
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
||||||
<a
|
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
||||||
href="https://manifold.markets/home"
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
target="_blank"
|
width="550"></a></td>
|
||||||
><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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="left"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
font-family: Arial, sans-serif;
|
<p class="text-build-content"
|
||||||
font-size: 18px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
letter-spacing: normal;
|
data-testid="4XoHRGw1Y"><span
|
||||||
line-height: 1;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
text-align: left;
|
Hi {{name}},</span></p>
|
||||||
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'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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="center"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
<div
|
||||||
font-size: 0px;
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
padding: 10px 25px 25px 25px;
|
<p class="text-build-content"
|
||||||
padding-top: 10px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
padding-right: 25px;
|
data-testid="4XoHRGw1Y"><span
|
||||||
padding-bottom: 25px;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||||
padding-left: 25px;
|
using Manifold Markets. Running low
|
||||||
word-break: break-word;
|
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
||||||
"
|
</div>
|
||||||
>
|
</td>
|
||||||
<table
|
</tr>
|
||||||
border="0"
|
<tr>
|
||||||
cellpadding="0"
|
<td>
|
||||||
cellspacing="0"
|
<p></p>
|
||||||
role="presentation"
|
</td>
|
||||||
style="
|
</tr>
|
||||||
border-collapse: collapse;
|
<tr>
|
||||||
border-spacing: 0px;
|
<td align="center">
|
||||||
"
|
<table cellspacing="0" cellpadding="0">
|
||||||
>
|
<tr>
|
||||||
<tbody>
|
<td>
|
||||||
<tr>
|
<table cellspacing="0" cellpadding="0">
|
||||||
<td style="width: 550px">
|
<tr>
|
||||||
<a href="{{manalink}}" target="_blank">
|
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||||
<img
|
<a href="{{manalink}}" target="_blank"
|
||||||
alt="Get M$500"
|
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;">
|
||||||
height="auto"
|
Claim M$500
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png"
|
</a>
|
||||||
style="
|
</td>
|
||||||
border: none;
|
</tr>
|
||||||
display: block;
|
</table>
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="550"
|
|
||||||
/></a>
|
|
||||||
<< /td>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
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;">
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
font-family: Arial, sans-serif;
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||||
font-size: 18px;
|
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||||
letter-spacing: normal;
|
you know, besides making correct predictions, there are
|
||||||
line-height: 1;
|
plenty of other ways to earn mana?</span></p>
|
||||||
text-align: left;
|
<ul>
|
||||||
color: #000000;
|
<li style="line-height:23px;"><span
|
||||||
"
|
style="font-family:Arial, sans-serif;font-size:18px;">Predicting
|
||||||
>
|
consecutive days to earn streak rewards</span></li>
|
||||||
<p
|
<li style="line-height:23px;"><span
|
||||||
class="text-build-content"
|
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||||
style="
|
tips on comments and markets</span></li>
|
||||||
line-height: 23px;
|
<li style="line-height:23px;"><span
|
||||||
text-align: center;
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
margin: 10px 0;
|
predictor bonus for each user who predicts on your
|
||||||
margin-top: 10px;
|
markets</span></li>
|
||||||
"
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
data-testid="3Q8BP69fq"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
>
|
target="_blank" href="https://manifold.markets/referrals"><span
|
||||||
<span
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||||
style="
|
friends</u></span></a></span></li>
|
||||||
color: #000000;
|
<li style="line-height:23px;"><a class="link-build-content"
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||||
font-size: 18px;
|
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||||
"
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||||
>If you are still engaging with our markets then
|
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||||
at this point you might as well join our </span
|
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
><a
|
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://discord.gg/VARzUpyCSa"
|
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||||
><span
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||||
style="
|
feedback</u></span></a></li>
|
||||||
color: #0c21bf;
|
</ul>
|
||||||
font-family: Arial;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||||
font-size: 18px;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
"
|
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||||
><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'd be willing to make a market betting
|
|
||||||
you'll stay.</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
class="text-build-content"
|
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||||
data-testid="3Q8BP69fq"
|
from Manifold</span></p>
|
||||||
style="margin: 10px 0"
|
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||||
></p>
|
style="margin: 10px 0; margin-bottom: 10px;"> </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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -415,91 +232,70 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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]-->
|
<!--[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">
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
||||||
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
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%;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
font-size: 0px;
|
width="100%">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: top; padding: 0">
|
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||||
<table
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
border="0"
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
cellpadding="0"
|
</div>
|
||||||
cellspacing="0"
|
<!--[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]-->
|
||||||
role="presentation"
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
width="100%"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
>
|
style="width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
||||||
align="center"
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
style="
|
<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%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 10px 25px;
|
padding: 10px 25px;
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<div style="
|
||||||
<div
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
style="
|
sans-serif;
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
href="{{unsubscribeLink}}"
|
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
"
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
target="_blank"
|
|
||||||
>click here to unsubscribe</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
@ -516,4 +312,5 @@
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -214,10 +214,12 @@
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent
|
||||||
to {{name}}, <a href="{{unsubscribeLink}}"
|
to {{name}},
|
||||||
style="color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to
|
color: inherit;
|
||||||
unsubscribe</a>.</p>
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -137,7 +137,7 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
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>
|
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -286,9 +286,12 @@
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { DOMAIN } from '../../common/envs/constants'
|
import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Answer } from '../../common/answer'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Comment } from '../../common/comment'
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
notification_subscription_types,
|
||||||
|
PrivateUser,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { getFunctionUrl } from '../../common/api'
|
|
||||||
import { richTextToString } from '../../common/util/parse'
|
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
|
import {
|
||||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
notification_reason_types,
|
||||||
|
getDestinationsForUser,
|
||||||
|
} from '../../common/notification'
|
||||||
|
|
||||||
export const sendMarketResolutionEmail = async (
|
export const sendMarketResolutionEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
investment: number,
|
investment: number,
|
||||||
payout: number,
|
payout: number,
|
||||||
creator: User,
|
creator: User,
|
||||||
|
@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async (
|
||||||
resolutionProbability?: number,
|
resolutionProbability?: number,
|
||||||
resolutions?: { [outcome: string]: number }
|
resolutions?: { [outcome: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const user = await getUser(userId)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
const outcome = toDisplayResolution(
|
const outcome = toDisplayResolution(
|
||||||
|
@ -53,13 +52,10 @@ export const sendMarketResolutionEmail = async (
|
||||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||||
|
|
||||||
const creatorPayoutText =
|
const creatorPayoutText =
|
||||||
creatorPayout >= 1 && userId === creator.id
|
creatorPayout >= 1 && privateUser.id === creator.id
|
||||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const emailType = 'market-resolved'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const displayedInvestment =
|
const displayedInvestment =
|
||||||
Number.isNaN(investment) || investment < 0
|
Number.isNaN(investment) || investment < 0
|
||||||
? formatMoney(0)
|
? formatMoney(0)
|
||||||
|
@ -154,11 +150,12 @@ export const sendWelcomeEmail = async (
|
||||||
) => {
|
) => {
|
||||||
if (!privateUser || !privateUser.email) return
|
if (!privateUser || !privateUser.email) return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -166,7 +163,7 @@ export const sendWelcomeEmail = async (
|
||||||
'welcome',
|
'welcome',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold Markets one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
'creating-market',
|
'creating-market',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -282,15 +279,18 @@ export const sendThankYouEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes(
|
||||||
|
'email'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'thank_you_for_purchases' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -298,7 +298,7 @@ export const sendThankYouEmail = async (
|
||||||
'thank-you',
|
'thank-you',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -307,16 +307,15 @@ export const sendThankYouEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendMarketCloseEmail = async (
|
export const sendMarketCloseEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
if (
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
!privateUser ||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
if (!privateUser.email || !sendToEmail) return
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { username, name, id: userId } = user
|
const { username, name, id: userId } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async (
|
||||||
const { question, slug, volume } = contract
|
const { question, slug, volume } = contract
|
||||||
|
|
||||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||||
const emailType = 'market-resolve'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewCommentEmail = async (
|
export const sendNewCommentEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
commentCreator: User,
|
commentCreator: User,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
comment: Comment,
|
commentText: string,
|
||||||
|
commentId: string,
|
||||||
bet?: Bet,
|
bet?: Bet,
|
||||||
answerText?: string,
|
answerText?: string,
|
||||||
answerId?: string
|
answerId?: string
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromCommentEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question } = contract
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
|
const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
|
||||||
const emailType = 'market-comment'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||||
const { content } = comment
|
|
||||||
const text = richTextToString(content)
|
|
||||||
|
|
||||||
let betDescription = ''
|
let betDescription = ''
|
||||||
if (bet) {
|
if (bet) {
|
||||||
|
@ -380,7 +371,7 @@ export const sendNewCommentEmail = async (
|
||||||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||||
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
const answerNumber = `#${answerId}`
|
const answerNumber = answerId ? `#${answerId}` : ''
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -391,7 +382,7 @@ export const sendNewCommentEmail = async (
|
||||||
answerNumber,
|
answerNumber,
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -412,7 +403,7 @@ export const sendNewCommentEmail = async (
|
||||||
{
|
{
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -423,29 +414,24 @@ export const sendNewCommentEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewAnswerEmail = async (
|
export const sendNewAnswerEmail = async (
|
||||||
answer: Answer,
|
reason: notification_reason_types,
|
||||||
contract: Contract
|
privateUser: PrivateUser,
|
||||||
|
name: string,
|
||||||
|
text: string,
|
||||||
|
contract: Contract,
|
||||||
|
avatarUrl?: string
|
||||||
) => {
|
) => {
|
||||||
// Send to just the creator for now.
|
const { creatorId } = contract
|
||||||
const { creatorId: userId } = contract
|
|
||||||
|
|
||||||
// Don't send the creator's own answers.
|
// Don't send the creator's own answers.
|
||||||
if (answer.userId === userId) return
|
if (privateUser.id === creatorId) return
|
||||||
|
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question, creatorUsername, slug } = contract
|
||||||
const { name, avatarUrl, text } = answer
|
|
||||||
|
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
|
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 subject = `New answer on ${question}`
|
||||||
const from = `${name} <info@manifold.markets>`
|
const from = `${name} <info@manifold.markets>`
|
||||||
|
@ -474,12 +460,15 @@ export const sendInterestingMarketsEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser?.unsubscribedFromWeeklyTrendingEmails
|
!privateUser.notificationSubscriptionTypes.trending_markets.includes(
|
||||||
|
'email'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const emailType = 'weekly-trending'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
|
'trending_markets' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -490,7 +479,7 @@ export const sendInterestingMarketsEmail = async (
|
||||||
'interesting-markets',
|
'interesting-markets',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink: unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
|
|
||||||
question1Title: contractsToSend[0].question,
|
question1Title: contractsToSend[0].question,
|
||||||
question1Link: contractUrl(contractsToSend[0]),
|
question1Link: contractUrl(contractsToSend[0]),
|
||||||
|
|
|
@ -3,7 +3,6 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getUserByUsername } from './utils'
|
import { getPrivateUser, getUserByUsername } from './utils'
|
||||||
import { sendMarketCloseEmail } from './emails'
|
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
|
|
||||||
export const marketCloseNotifications = functions
|
export const marketCloseNotifications = functions
|
||||||
|
@ -56,7 +55,6 @@ async function sendMarketCloseEmails() {
|
||||||
const privateUser = await getPrivateUser(user.id)
|
const privateUser = await getPrivateUser(user.id)
|
||||||
if (!privateUser) continue
|
if (!privateUser) continue
|
||||||
|
|
||||||
await sendMarketCloseEmail(user, privateUser, contract)
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { compact, uniq } from 'lodash'
|
import { compact } from 'lodash'
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import { getContract, getUser, getValues } from './utils'
|
||||||
import { ContractComment } from '../../common/comment'
|
import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import {
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
createCommentOrAnswerOrUpdatedContractNotification,
|
|
||||||
filterUserIdsForOnlyFollowerIds,
|
|
||||||
} from './create-notification'
|
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
|
@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions
|
||||||
const comments = await getValues<ContractComment>(
|
const comments = await getValues<ContractComment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
)
|
)
|
||||||
const relatedSourceType = comment.replyToCommentId
|
const repliedToType = answer
|
||||||
? 'comment'
|
|
||||||
: comment.answerOutcome
|
|
||||||
? 'answer'
|
? 'answer'
|
||||||
|
: comment.replyToCommentId
|
||||||
|
? 'comment'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const repliedUserId = comment.replyToCommentId
|
const repliedUserId = comment.replyToCommentId
|
||||||
|
@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
contract,
|
contract,
|
||||||
{
|
{
|
||||||
relatedSourceType,
|
repliedToType,
|
||||||
|
repliedToId: comment.replyToCommentId || answer?.id,
|
||||||
|
repliedToContent: answer ? answer.text : undefined,
|
||||||
repliedUserId,
|
repliedUserId,
|
||||||
taggedUserIds: compact(parseMentions(comment.content)),
|
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')
|
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||||
|
|
||||||
const previousValue = change.before.data() as Contract
|
const previousValue = change.before.data() as Contract
|
||||||
if (previousValue.isResolved !== contract.isResolved) {
|
if (
|
||||||
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 (
|
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.question !== contract.question
|
previousValue.question !== contract.question
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { difference, mapValues, groupBy, sumBy } from 'lodash'
|
import { mapValues, groupBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
|
@ -8,10 +8,8 @@ import {
|
||||||
MultipleChoiceContract,
|
MultipleChoiceContract,
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, isProd, payUser } from './utils'
|
||||||
import { sendMarketResolutionEmail } from './emails'
|
|
||||||
import {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
|
@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { floatingEqual } from '../../common/util/math'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -163,15 +161,45 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
await sendResolutionEmails(
|
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,
|
||||||
|
contract.id + '-resolution',
|
||||||
|
resolutionText,
|
||||||
|
contract,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
bets,
|
bets,
|
||||||
userPayoutsWithoutLoans,
|
userInvestments,
|
||||||
|
userPayouts: userPayoutsWithoutLoans,
|
||||||
creator,
|
creator,
|
||||||
creatorPayout,
|
creatorPayout,
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
resolutionProbability,
|
resolutionProbability,
|
||||||
resolutions
|
resolutions,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return updatedContract
|
return updatedContract
|
||||||
|
@ -189,51 +217,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
.then(() => ({ status: 'success' }))
|
.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) {
|
function getResolutionParams(contract: Contract, body: string) {
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
|
|
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
|
@ -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'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
import { PrivateUser, User } from 'common/user'
|
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
|
||||||
import { STARTING_BALANCE } from 'common/economy'
|
import { STARTING_BALANCE } from 'common/economy'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
@ -21,6 +21,7 @@ async function main() {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
|
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.totalDeposits === undefined) {
|
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 React from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export const FollowMarketModal = (props: {
|
export const WatchMarketModal = (props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (b: boolean) => void
|
setOpen: (b: boolean) => void
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||||
<span className={'ml-2'}>
|
<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
|
clicking the
|
||||||
<EyeIcon
|
<EyeIcon
|
||||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
️ button on a question.
|
️ button on them.
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-indigo-700'}>
|
<span className={'text-indigo-700'}>
|
||||||
• What types of notifications will I receive?
|
• What types of notifications will I receive?
|
||||||
</span>
|
</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You'll receive in-app notifications for new comments, answers, and
|
You'll receive notifications for new comments, answers, and updates
|
||||||
updates to the question.
|
to the question. See the notifications settings pages to customize
|
||||||
|
which types of notifications you receive on watched markets.
|
||||||
</span>
|
</span>
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
||||||
import { useContractFollows } from 'web/hooks/use-follows'
|
import { useContractFollows } from 'web/hooks/use-follows'
|
||||||
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
||||||
import { track } from 'web/lib/service/analytics'
|
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 { useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export const FollowMarketButton = (props: {
|
||||||
Watch
|
Watch
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<FollowMarketModal
|
<WatchMarketModal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={`You ${
|
title={`You ${
|
||||||
|
|
320
web/components/notification-settings.tsx
Normal file
320
web/components/notification-settings.tsx
Normal file
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
34
web/components/switch-setting.tsx
Normal file
34
web/components/switch-setting.tsx
Normal file
|
@ -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 { useMemo } from 'react'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { Notification } from 'common/notification'
|
import { Notification } from 'common/notification'
|
||||||
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map, partition } from 'lodash'
|
import { groupBy, map, partition } from 'lodash'
|
||||||
|
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
|
||||||
if (!result.data) return undefined
|
if (!result.data) return undefined
|
||||||
const notifications = result.data as Notification[]
|
const notifications = result.data as Notification[]
|
||||||
|
|
||||||
return getAppropriateNotifications(
|
return notifications.filter((n) => !n.isSeenOnHref)
|
||||||
notifications,
|
}, [result.data])
|
||||||
privateUser.notificationPreferences
|
|
||||||
).filter((n) => !n.isSeenOnHref)
|
|
||||||
}, [privateUser.notificationPreferences, result.data])
|
|
||||||
|
|
||||||
return notifications
|
return notifications
|
||||||
}
|
}
|
||||||
|
@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
})
|
})
|
||||||
return notificationGroups
|
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 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 { Notification, notification_source_types } from 'common/notification'
|
||||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
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 { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
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 { SEO } from 'web/components/SEO'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
@ -56,24 +56,49 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const privateUser = usePrivateUser()
|
const privateUser = usePrivateUser()
|
||||||
|
const router = useRouter()
|
||||||
|
const [navigateToSection, setNavigateToSection] = useState<string>()
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (privateUser === null) Router.push('/')
|
if (privateUser === null) Router.push('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = { ...router.query }
|
||||||
|
if (query.section) {
|
||||||
|
setNavigateToSection(query.section as string)
|
||||||
|
setActiveIndex(1)
|
||||||
|
}
|
||||||
|
}, [router.query])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
||||||
<Title text={'Notifications'} className={'hidden md:block'} />
|
<Title text={'Notifications'} className={'hidden md:block'} />
|
||||||
<SEO title="Notifications" description="Manifold user notifications" />
|
<SEO title="Notifications" description="Manifold user notifications" />
|
||||||
|
|
||||||
{privateUser && (
|
{privateUser && router.isReady && (
|
||||||
<div>
|
<div>
|
||||||
<Tabs
|
<ControlledTabs
|
||||||
currentPageForAnalytics={'notifications'}
|
currentPageForAnalytics={'notifications'}
|
||||||
labelClassName={'pb-2 pt-1 '}
|
labelClassName={'pb-2 pt-1 '}
|
||||||
className={'mb-0 sm:mb-2'}
|
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={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
|
@ -82,9 +107,9 @@ export default function Notifications() {
|
||||||
{
|
{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
content: (
|
content: (
|
||||||
<div className={''}>
|
<NotificationSettings
|
||||||
<NotificationSettings />
|
navigateToSection={navigateToSection}
|
||||||
</div>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
@ -992,6 +1017,7 @@ function getReasonForShowingNotification(
|
||||||
) {
|
) {
|
||||||
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
||||||
let reasonText: string
|
let reasonText: string
|
||||||
|
// TODO: we could leave out this switch and just use the reason field now that they have more information
|
||||||
switch (sourceType) {
|
switch (sourceType) {
|
||||||
case 'comment':
|
case 'comment':
|
||||||
if (reason === 'reply_to_users_answer')
|
if (reason === 'reply_to_users_answer')
|
||||||
|
@ -1003,7 +1029,7 @@ function getReasonForShowingNotification(
|
||||||
else reasonText = justSummary ? `commented` : `commented on`
|
else reasonText = justSummary ? `commented` : `commented on`
|
||||||
break
|
break
|
||||||
case 'contract':
|
case 'contract':
|
||||||
if (reason === 'you_follow_user')
|
if (reason === 'contract_from_followed_user')
|
||||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||||
else if (sourceUpdateType === 'resolved')
|
else if (sourceUpdateType === 'resolved')
|
||||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||||
|
@ -1011,7 +1037,8 @@ function getReasonForShowingNotification(
|
||||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||||
break
|
break
|
||||||
case 'answer':
|
case 'answer':
|
||||||
if (reason === 'on_users_contract') reasonText = `answered your question `
|
if (reason === 'answer_on_your_contract')
|
||||||
|
reasonText = `answered your question `
|
||||||
else reasonText = `answered`
|
else reasonText = `answered`
|
||||||
break
|
break
|
||||||
case 'follow':
|
case 'follow':
|
||||||
|
|
Loading…
Reference in New Issue
Block a user