From 5c6328ffc263fb2bcbe8cbb6862b6bc488208fb5 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 12 Sep 2022 10:34:56 -0600 Subject: [PATCH] [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 --- common/notification.ts | 105 ++- common/user.ts | 190 +++++ firebase.json | 22 +- firestore.rules | 8 +- functions/.gitignore | 1 + functions/src/create-answer.ts | 7 +- functions/src/create-group.ts | 2 +- functions/src/create-notification.ts | 633 ++++++++------- functions/src/create-user.ts | 7 +- functions/src/email-templates/500-mana.html | 9 +- .../src/email-templates/creating-market.html | 4 +- .../email-templates/interesting-markets.html | 5 +- .../market-answer-comment.html | 17 +- .../src/email-templates/market-answer.html | 11 +- .../src/email-templates/market-close.html | 11 +- .../src/email-templates/market-comment.html | 11 +- .../src/email-templates/market-resolved.html | 11 +- functions/src/email-templates/one-week.html | 753 +++++++----------- functions/src/email-templates/thank-you.html | 10 +- functions/src/email-templates/welcome.html | 11 +- functions/src/emails.ts | 165 ++-- functions/src/market-close-notifications.ts | 2 - .../src/on-create-comment-on-contract.ts | 40 +- functions/src/on-update-contract.ts | 27 +- functions/src/resolve-market.ts | 95 +-- .../create-new-notification-preferences.ts | 30 + functions/src/scripts/create-private-users.ts | 3 +- web/components/NotificationSettings.tsx | 236 ------ ...arket-modal.tsx => watch-market-modal.tsx} | 11 +- web/components/follow-market-button.tsx | 4 +- web/components/notification-settings.tsx | 320 ++++++++ web/components/switch-setting.tsx | 34 + web/hooks/use-notifications.ts | 35 +- web/pages/notifications.tsx | 49 +- 34 files changed, 1554 insertions(+), 1325 deletions(-) create mode 100644 functions/src/scripts/create-new-notification-preferences.ts delete mode 100644 web/components/NotificationSettings.tsx rename web/components/contract/{follow-market-modal.tsx => watch-market-modal.tsx} (74%) create mode 100644 web/components/notification-settings.tsx create mode 100644 web/components/switch-setting.tsx diff --git a/common/notification.ts b/common/notification.ts index 9ec320fa..42dbbf35 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,3 +1,6 @@ +import { notification_subscription_types, PrivateUser } from './user' +import { DOMAIN } from './envs/constants' + export type Notification = { id: string userId: string @@ -51,28 +54,106 @@ export type notification_source_update_types = | 'deleted' | 'closed' +/* Optional - if possible use a keyof notification_subscription_types */ export type notification_reason_types = | 'tagged_user' - | 'on_users_contract' - | 'on_contract_with_users_shares_in' - | 'on_contract_with_users_shares_out' - | 'on_contract_with_users_answer' - | 'on_contract_with_users_comment' - | 'reply_to_users_answer' - | 'reply_to_users_comment' | 'on_new_follow' - | 'you_follow_user' - | 'added_you_to_group' + | 'contract_from_followed_user' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' - | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' - | 'you_follow_contract' - | 'liked_your_contract' | 'liked_and_tipped_your_contract' + | 'comment_on_your_contract' + | 'answer_on_your_contract' + | 'comment_on_contract_you_follow' + | 'answer_on_contract_you_follow' + | 'update_on_contract_you_follow' + | 'resolution_on_contract_you_follow' + | 'comment_on_contract_with_users_shares_in' + | 'answer_on_contract_with_users_shares_in' + | 'update_on_contract_with_users_shares_in' + | 'resolution_on_contract_with_users_shares_in' + | 'comment_on_contract_with_users_answer' + | 'update_on_contract_with_users_answer' + | 'resolution_on_contract_with_users_answer' + | 'answer_on_contract_with_users_answer' + | 'comment_on_contract_with_users_comment' + | 'answer_on_contract_with_users_comment' + | 'update_on_contract_with_users_comment' + | 'resolution_on_contract_with_users_comment' + | 'reply_to_users_answer' + | 'reply_to_users_comment' + | 'your_contract_closed' + | 'subsidized_your_market' + +// Adding a new key:value here is optional, you can just use a key of notification_subscription_types +// You might want to add a key:value here if there will be multiple notification reasons that map to the same +// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to +// 'all_comments_on_watched_markets' subscription type +// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types +export const notificationReasonToSubscriptionType: Partial< + Record +> = { + 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}`, + } +} diff --git a/common/user.ts b/common/user.ts index f15865cf..f8b4f8d8 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { filterDefined } from './util/array' + export type User = { id: string createdTime: number @@ -63,9 +65,60 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string + /** @deprecated - use notificationSubscriptionTypes */ notificationPreferences?: notification_subscribe_types + notificationSubscriptionTypes: notification_subscription_types } +export type notification_destination_types = 'email' | 'browser' +export type notification_subscription_types = { + // Watched Markets + all_comments_on_watched_markets: notification_destination_types[] + all_answers_on_watched_markets: notification_destination_types[] + + // Comments + tipped_comments_on_watched_markets: notification_destination_types[] + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_types[] + all_replies_to_my_answers_on_watched_markets: notification_destination_types[] + all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // Answers + answers_by_followed_users_on_watched_markets: notification_destination_types[] + answers_by_market_creator_on_watched_markets: notification_destination_types[] + all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // On users' markets + your_contract_closed: notification_destination_types[] + all_comments_on_my_markets: notification_destination_types[] + all_answers_on_my_markets: notification_destination_types[] + subsidized_your_market: notification_destination_types[] + + // Market updates + resolutions_on_watched_markets: notification_destination_types[] + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] + market_updates_on_watched_markets: notification_destination_types[] + market_updates_on_watched_markets_with_shares_in: notification_destination_types[] + probability_updates_on_watched_markets: notification_destination_types[] + + // Balance Changes + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_types[] + unique_bettors_on_your_contract: notification_destination_types[] + tips_on_your_comments: notification_destination_types[] + tips_on_your_markets: notification_destination_types[] + limit_order_fills: notification_destination_types[] + + // General + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + contract_from_followed_user: notification_destination_types[] + trending_markets: notification_destination_types[] + profit_loss_updates: notification_destination_types[] + onboarding_flow: notification_destination_types[] + thank_you_for_purchases: notification_destination_types[] +} export type notification_subscribe_types = 'all' | 'less' | 'none' export type PortfolioMetrics = { @@ -78,3 +131,140 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' + +export const getDefaultNotificationSettings = ( + userId: string, + privateUser?: PrivateUser, + noEmails?: boolean +) => { + const prevPref = privateUser?.notificationPreferences ?? 'all' + const wantsLess = prevPref === 'less' + const wantsAll = prevPref === 'all' + const { + unsubscribedFromCommentEmails, + unsubscribedFromAnswerEmails, + unsubscribedFromResolutionEmails, + unsubscribedFromWeeklyTrendingEmails, + unsubscribedFromGenericEmails, + } = privateUser || {} + + const constructPref = (browserIf: boolean, emailIf: boolean) => { + const browser = browserIf ? 'browser' : undefined + const email = noEmails ? undefined : emailIf ? 'email' : undefined + return filterDefined([browser, email]) as notification_destination_types[] + } + return { + // Watched Markets + all_comments_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromCommentEmails + ), + all_answers_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // Comments + tips_on_your_comments: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + comments_by_followed_users_on_watched_markets: constructPref( + wantsAll, + false + ), + all_replies_to_my_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_replies_to_my_answers_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromCommentEmails + ), + + // Answers + answers_by_followed_users_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + answers_by_market_creator_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // On users' markets + your_contract_closed: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), // High priority + all_comments_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_answers_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + subsidized_your_market: constructPref(wantsAll || wantsLess, true), + + // Market updates + resolutions_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), + market_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + market_updates_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + false + ), + resolutions_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), + + //Balance Changes + loan_income: constructPref(wantsAll || wantsLess, false), + betting_streaks: constructPref(wantsAll || wantsLess, false), + referral_bonuses: constructPref(wantsAll || wantsLess, true), + unique_bettors_on_your_contract: constructPref( + wantsAll || wantsLess, + false + ), + tipped_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + tips_on_your_markets: constructPref(wantsAll || wantsLess, true), + limit_order_fills: constructPref(wantsAll || wantsLess, false), + + // General + tagged_user: constructPref(wantsAll || wantsLess, true), + on_new_follow: constructPref(wantsAll || wantsLess, true), + contract_from_followed_user: constructPref(wantsAll || wantsLess, true), + trending_markets: constructPref( + false, + !unsubscribedFromWeeklyTrendingEmails + ), + profit_loss_updates: constructPref(false, true), + probability_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + thank_you_for_purchases: constructPref( + false, + !unsubscribedFromGenericEmails + ), + onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + } as notification_subscription_types +} diff --git a/firebase.json b/firebase.json index 25f9b61f..5dea5ade 100644 --- a/firebase.json +++ b/firebase.json @@ -2,10 +2,30 @@ "functions": { "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions/dist", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.rules b/firestore.rules index 9a72e454..d24d4097 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); } match /private-users/{userId}/views/{viewId} { @@ -161,7 +161,7 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isSeen', 'viewTime']); } - + match /{somePath=**}/groupMembers/{memberId} { allow read; } @@ -170,7 +170,7 @@ service cloud.firestore { allow read; } - match /groups/{groupId} { + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) @@ -184,7 +184,7 @@ service cloud.firestore { match /groupMembers/{memberId}{ allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); - allow delete: if request.auth.uid == resource.data.userId; + allow delete: if request.auth.uid == resource.data.userId; } function isGroupMember() { diff --git a/functions/.gitignore b/functions/.gitignore index 58f30dcb..bd3d0c29 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -17,4 +17,5 @@ package-lock.json ui-debug.log firebase-debug.log firestore-debug.log +pubsub-debug.log firestore_export/ diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 0b8b4e7a..cc05d817 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -5,8 +5,7 @@ import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' -import { getContract, getValues } from './utils' -import { sendNewAnswerEmail } from './emails' +import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' const bodySchema = z.object({ @@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index fc64aeff..9d00bb0b 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -10,7 +10,7 @@ import { MAX_GROUP_NAME_LENGTH, MAX_ID_LENGTH, } from '../../common/group' -import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { APIError, newEndpoint, validate } from './api' import { z } from 'zod' const bodySchema = z.object({ diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 131d6e85..356ad200 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,13 +1,12 @@ import * as admin from 'firebase-admin' import { + getDestinationsForUser, Notification, notification_reason_types, - notification_source_update_types, - notification_source_types, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues, log } from './utils' +import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -15,20 +14,25 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from '../../common/util/parse' import { Like } from '../../common/like' +import { + sendMarketCloseEmail, + sendMarketResolutionEmail, + sendNewAnswerEmail, + sendNewCommentEmail, +} from './emails' const firestore = admin.firestore() -type user_to_reason_texts = { +type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'contract' | 'liquidity' | 'follow', + sourceUpdateType: 'closed' | 'created', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -41,9 +45,9 @@ export const createNotification = async ( ) => { const { contract: sourceContract, recipients, slug, title } = miscData ?? {} - const shouldGetNotification = ( + const shouldReceiveNotification = ( userId: string, - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && @@ -51,18 +55,25 @@ export const createNotification = async ( ) } - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts + const sendNotificationsIfSettingsPermit = async ( + userToReasonTexts: recipients_to_reason_texts ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { + for (const userId in userToReasonTexts) { + const { reason } = userToReasonTexts[userId] + const privateUser = await getPrivateUser(userId) + if (!privateUser) continue + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, - reason: userToReasonTexts[userId].reason, + reason, createdTime: Date.now(), isSeen: false, sourceId, @@ -80,12 +91,32 @@ export const createNotification = async ( sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) - }) - ) + } + + if (!sendToEmail) continue + + if (reason === 'your_contract_closed' && privateUser && sourceContract) { + // TODO: include number and names of bettors waiting for creator to resolve their market + await sendMarketCloseEmail( + reason, + sourceUser, + privateUser, + sourceContract + ) + } else if (reason === 'tagged_user') { + // TODO: send email to tagged user in new contract + } else if (reason === 'subsidized_your_market') { + // TODO: send email to creator of market that was subsidized + } else if (reason === 'contract_from_followed_user') { + // TODO: send email to follower of user who created market + } else if (reason === 'on_new_follow') { + // TODO: send email to user who was followed + } + } } const notifyUsersFollowers = async ( - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { const followers = await firestore .collectionGroup('follows') @@ -96,72 +127,36 @@ export const createNotification = async ( const followerUserId = doc.ref.parent.parent?.id if ( followerUserId && - shouldGetNotification(followerUserId, userToReasonTexts) + shouldReceiveNotification(followerUserId, userToReasonTexts) ) { userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', + reason: 'contract_from_followed_user', } } }) } - const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, - followedUserId: string - ) => { - if (shouldGetNotification(followedUserId, userToReasonTexts)) - userToReasonTexts[followedUserId] = { - reason: 'on_new_follow', - } - } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, userIds: (string | undefined)[] ) => { userIds.forEach((id) => { - if (id && shouldGetNotification(id, userToReasonTexts)) + if (id && shouldReceiveNotification(id, userToReasonTexts)) userToReasonTexts[id] = { reason: 'tagged_user', } }) } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract, - options?: { force: boolean } - ) => { - if ( - options?.force || - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } - } - - const notifyUserAddedToGroup = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - reason: 'added_you_to_group', - } - } - - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. + const userToReasonTexts: recipients_to_reason_texts = {} if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + if (shouldReceiveNotification(recipients[0], userToReasonTexts)) + userToReasonTexts[recipients[0]] = { + reason: 'on_new_follow', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'created' && @@ -169,123 +164,198 @@ export const createNotification = async ( ) { await notifyUsersFollowers(userToReasonTexts) notifyTaggedUsers(userToReasonTexts, recipients ?? []) + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'your_contract_closed', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'liquidity' && sourceUpdateType === 'created' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract) + if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'subsidized_your_market', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } - - await createUsersNotifications(userToReasonTexts) } export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'comment' | 'answer' | 'contract', + sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { - relatedSourceType?: notification_source_types + repliedToType?: 'comment' | 'answer' + repliedToId?: string + repliedToContent?: string repliedUserId?: string taggedUserIds?: string[] + }, + resolutionData?: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } } ) => { - const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + const { + repliedToType, + repliedToContent, + repliedUserId, + taggedUserIds, + repliedToId, + } = miscData ?? {} - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts - ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { - const notificationRef = firestore - .collection(`/users/${userId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId, - reason: userToReasonTexts[userId].reason, - createdTime: Date.now(), - isSeen: false, - sourceId, - sourceType, - sourceUpdateType, - sourceContractId: sourceContract.id, - sourceUserName: sourceUser.name, - sourceUserUsername: sourceUser.username, - sourceUserAvatarUrl: sourceUser.avatarUrl, - sourceText, - sourceContractCreatorUsername: sourceContract.creatorUsername, - sourceContractTitle: sourceContract.question, - sourceContractSlug: sourceContract.slug, - sourceSlug: sourceContract.slug, - sourceTitle: sourceContract.question, - } - await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } + const recipientIdsList: string[] = [] - // get contract follower documents and check here if they're a follower const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) .get() const contractFollowersIds = contractFollowersSnap.docs.map( (doc) => doc.data().id ) - log('contractFollowerIds', contractFollowersIds) + + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } - const shouldGetNotification = ( + const sendNotificationsIfSettingsPermit = async ( userId: string, - userToReasonTexts: user_to_reason_texts + reason: notification_reason_types ) => { - return ( - sourceUser.id != userId && - !Object.keys(userToReasonTexts).includes(userId) + if ( + !stillFollowingContract(sourceContract.creatorId) || + sourceUser.id == userId || + recipientIdsList.includes(userId) + ) + return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason ) - } - const notifyContractFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - for (const userId of contractFollowersIds) { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'you_follow_contract', - } + 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 notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts - ) => { - if ( - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && - stillFollowingContract(sourceContract.creatorId) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } + const notifyContractFollowers = async () => { + 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 notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyContractCreator = async () => { + await sendNotificationsIfSettingsPermit( + sourceContract.creatorId, + sourceType === 'comment' + ? 'comment_on_your_contract' + : 'answer_on_your_contract' + ) + } + + const notifyOtherAnswerersOnContract = async () => { const answers = await getValues( firestore .collection('contracts') @@ -293,20 +363,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_answer' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_answer' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_answer' + : 'resolution_on_contract_with_users_answer' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) + ) } - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyOtherCommentersOnContract = async () => { const comments = await getValues( firestore .collection('contracts') @@ -314,20 +387,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_comment' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_comment' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_comment' + : 'resolution_on_contract_with_users_comment' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) + ) } - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() @@ -343,88 +419,73 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } ) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) + ) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if ( - shouldGetNotification(relatedUserId, userToReasonTexts) && - stillFollowingContract(relatedUserId) - ) { - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } + const notifyRepliedUser = async () => { + if (sourceType === 'comment' && repliedUserId && repliedToType) + await sendNotificationsIfSettingsPermit( + repliedUserId, + repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - console.log('tagged user: ', id) - // Allowing non-following users to get tagged - if (id && shouldGetNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) + const notifyTaggedUsers = async () => { + if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0) + await Promise.all( + taggedUserIds.map((userId) => + sendNotificationsIfSettingsPermit(userId, 'tagged_user') + ) + ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) - liquidityProvidersIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) - ) { - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - } - }) + await Promise.all( + liquidityProvidersIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) + ) + ) } - const userToReasonTexts: user_to_reason_texts = {} - if (sourceType === 'comment') { - if (repliedUserId && relatedSourceType) - notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) - } - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyLiquidityProviders(userToReasonTexts) - await notifyBettorsOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - // if they weren't added previously, add them now - await notifyContractFollowers(userToReasonTexts) - - await createUsersNotifications(userToReasonTexts) + await notifyRepliedUser() + await notifyTaggedUsers() + await notifyContractCreator() + await notifyOtherAnswerersOnContract() + await notifyLiquidityProviders() + await notifyBettorsOnContract() + await notifyOtherCommentersOnContract() + // if they weren't notified previously, notify them now + await notifyContractFollowers() } export const createTipNotification = async ( @@ -436,8 +497,15 @@ export const createTipNotification = async ( contract?: Contract, group?: Group ) => { - const slug = group ? group.slug + `#${commentId}` : commentId + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + const slug = group ? group.slug + `#${commentId}` : commentId const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -461,6 +529,9 @@ export const createTipNotification = async ( sourceTitle: group?.name, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO: send notification to users that are watching the contract and want highly tipped comments only + // maybe TODO: send email notification to bet creator } export const createBetFillNotification = async ( @@ -471,6 +542,14 @@ export const createBetFillNotification = async ( contract: Contract, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'bet_fill' + ) + if (!sendToBrowser) return + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 @@ -496,38 +575,8 @@ export const createBetFillNotification = async ( sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export const createGroupCommentNotification = async ( - fromUser: User, - toUserId: string, - comment: Comment, - group: Group, - idempotencyKey: string -) => { - if (toUserId === fromUser.id) return - const notificationRef = firestore - .collection(`/users/${toUserId}/notifications`) - .doc(idempotencyKey) - const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` - const notification: Notification = { - id: idempotencyKey, - userId: toUserId, - reason: 'on_group_you_are_member_of', - createdTime: Date.now(), - isSeen: false, - sourceId: comment.id, - sourceType: 'comment', - sourceUpdateType: 'created', - sourceUserName: fromUser.name, - sourceUserUsername: fromUser.username, - sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), - sourceSlug, - sourceTitle: `${group.name}`, - isSeenOnHref: sourceSlug, - } - await notificationRef.set(removeUndefinedProps(notification)) + // maybe TODO: send email notification to bet creator } export const createReferralNotification = async ( @@ -538,6 +587,14 @@ export const createReferralNotification = async ( referredByContract?: Contract, referredByGroup?: Group ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'you_referred_user' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -575,6 +632,8 @@ export const createReferralNotification = async ( : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } export const createLoanIncomeNotification = async ( @@ -582,6 +641,14 @@ export const createLoanIncomeNotification = async ( idempotencyKey: string, income: number ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'loan_income' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -612,6 +679,14 @@ export const createChallengeAcceptedNotification = async ( acceptedAmount: number, contract: Contract ) => { + const privateUser = await getPrivateUser(challengeCreator.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'challenge_accepted' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() @@ -645,6 +720,14 @@ export const createBettingStreakBonusNotification = async ( amount: number, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'betting_streak_incremented' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${user.id}/notifications`) .doc(idempotencyKey) @@ -680,13 +763,24 @@ export const createLikeNotification = async ( contract: Contract, tip?: TipTxn ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'liked_and_tipped_your_contract' + ) + if (!sendToBrowser) return + + // not handling just likes, must include tip + if (!tip) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, - reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + reason: 'liked_and_tipped_your_contract', createdTime: Date.now(), isSeen: false, sourceId: like.id, @@ -703,20 +797,8 @@ export const createLikeNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export async function filterUserIdsForOnlyFollowerIds( - userIds: string[], - contractId: string -) { - // get contract follower documents and check here if they're a follower - const contractFollowersSnap = await firestore - .collection(`contracts/${contractId}/follows`) - .get() - const contractFollowersIds = contractFollowersSnap.docs.map( - (doc) => doc.data().id - ) - return userIds.filter((id) => contractFollowersIds.includes(id)) + // TODO send email notification } export const createUniqueBettorBonusNotification = async ( @@ -727,6 +809,15 @@ export const createUniqueBettorBonusNotification = async ( amount: number, idempotencyKey: string ) => { + console.log('createUniqueBettorBonusNotification') + const privateUser = await getPrivateUser(contractCreatorId) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'unique_bettors_on_your_contract' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${contractCreatorId}/notifications`) .doc(idempotencyKey) @@ -752,4 +843,6 @@ export const createUniqueBettorBonusNotification = async ( sourceContractCreatorUsername: contract.creatorUsername, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index eabe0fd0..71272222 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,7 +1,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { PrivateUser, User } from '../../common/user' +import { + getDefaultNotificationSettings, + PrivateUser, + User, +} from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, + notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 6c75f026..c8f6a171 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -284,9 +284,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
-

This e-mail has been sent to {{name}}, click here to unsubscribe.

+

This e-mail has been sent to {{name}}, + click here to manage your notifications. +

diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index df215bdc..c73f7458 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -491,10 +491,10 @@ ">

This e-mail has been sent to {{name}}, - click here to unsubscribe. + " target="_blank">click here to manage your notifications.

diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index d00b227e..7c3e653d 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -440,11 +440,10 @@

This e-mail has been sent to {{name}}, - click here to unsubscribe from future recommended markets. + " target="_blank">click here to manage your notifications.

diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index 4e1a2bfa..a19aa7c3 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -526,19 +526,10 @@ " >our Discord! Or, - unsubscribe. + click here to manage your notifications. diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 1f7fa5fa..b2d7f727 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index fa44c1d5..ee7976b0 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -485,14 +485,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 0b5b9a54..23e20dac 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index c1ff3beb..de29a0f1 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -500,14 +500,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index 94889772..b8e233d5 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -1,519 +1,316 @@ - - - 7th Day Anniversary Gift! - - - - - - - - - + + + + - - + - - + + + + + +
+ +
+ + + +
+ +
+ + + + - - -
+ - - - - - - - - - - - - + + +
- - - - - - -
- -
-
-
-

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

-
-
- - - - - - -
- - Get M$500 - << /td> -
-
-
-

- If you are still engaging with our markets then - at this point you might as well join our Discord server. - You can always leave if you dont like it but - I'd be willing to make a market betting - you'll stay. -

-

-
-

- Cheers, -

-

- David from Manifold -

-

-
-
- - -
-
- -
- - - - + + +
- -
- - + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

Thanks for + using Manifold Markets. Running low + on mana (M$)? Click the link below to receive a one time gift of M$500!

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

Did + you know, besides making correct predictions, there are + plenty of other ways to earn mana?

+ +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+
+
+ +
+
+ +
+ + + + diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 366709e3..dccec695 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -137,7 +137,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" data-testid="4XoHRGw1Y"> - 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.

@@ -286,9 +286,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
-

This e-mail has been sent to {{name}}, click here to unsubscribe.

+

This e-mail has been sent to {{name}}, + click here to manage your notifications. +

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2c9c6f12..b9d34363 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,10 +1,12 @@ import { DOMAIN } from '../../common/envs/constants' -import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' -import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { PrivateUser, User } from '../../common/user' +import { + notification_subscription_types, + PrivateUser, + User, +} from '../../common/user' import { formatLargeNumber, formatMoney, @@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getPrivateUser, getUser } from './utils' -import { getFunctionUrl } from '../../common/api' -import { richTextToString } from '../../common/util/parse' +import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' - -const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') +import { + notification_reason_types, + getDestinationsForUser, +} from '../../common/notification' export const sendMarketResolutionEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, investment: number, payout: number, creator: User, @@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const user = await getUser(userId) + const user = await getUser(privateUser.id) if (!user) return const outcome = toDisplayResolution( @@ -53,13 +52,10 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` const creatorPayoutText = - creatorPayout >= 1 && userId === creator.id + creatorPayout >= 1 && privateUser.id === creator.id ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const emailType = 'market-resolved' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - const displayedInvestment = Number.isNaN(investment) || investment < 0 ? formatMoney(0) @@ -154,11 +150,12 @@ export const sendWelcomeEmail = async ( ) => { if (!privateUser || !privateUser.email) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -166,7 +163,7 @@ export const sendWelcomeEmail = async ( 'welcome', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, - unsubscribeLink, + unsubscribeUrl, manalink: 'https://manifold.markets/link/lj4JbBvE', }, { @@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', 'creating-market', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -282,15 +279,18 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( + 'email' + ) ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'thank_you_for_purchases' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -298,7 +298,7 @@ export const sendThankYouEmail = async ( 'thank-you', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -307,16 +307,15 @@ export const sendThankYouEmail = async ( } export const sendMarketCloseEmail = async ( + reason: notification_reason_types, user: User, privateUser: PrivateUser, contract: Contract ) => { - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + + if (!privateUser.email || !sendToEmail) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` - const emailType = 'market-resolve' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` return await sendTemplateEmail( privateUser.email, @@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async ( } export const sendNewCommentEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, commentCreator: User, contract: Contract, - comment: Comment, + commentText: string, + commentId: string, bet?: Bet, answerText?: string, answerId?: string ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromCommentEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - const emailType = 'market-comment' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const { question } = contract + const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) let betDescription = '' if (bet) { @@ -380,7 +371,7 @@ export const sendNewCommentEmail = async ( const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { - const answerNumber = `#${answerId}` + const answerNumber = answerId ? `#${answerId}` : '' return await sendTemplateEmail( privateUser.email, @@ -391,7 +382,7 @@ export const sendNewCommentEmail = async ( answerNumber, commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -412,7 +403,7 @@ export const sendNewCommentEmail = async ( { commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -423,29 +414,24 @@ export const sendNewCommentEmail = async ( } export const sendNewAnswerEmail = async ( - answer: Answer, - contract: Contract + reason: notification_reason_types, + privateUser: PrivateUser, + name: string, + text: string, + contract: Contract, + avatarUrl?: string ) => { - // Send to just the creator for now. - const { creatorId: userId } = contract - + const { creatorId } = contract // Don't send the creator's own answers. - if (answer.userId === userId) return + if (privateUser.id === creatorId) return - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromAnswerEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract - const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const emailType = 'market-answer' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} ` @@ -474,12 +460,15 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - privateUser?.unsubscribedFromWeeklyTrendingEmails + !privateUser.notificationSubscriptionTypes.trending_markets.includes( + 'email' + ) ) return - const emailType = 'weekly-trending' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'trending_markets' as keyof notification_subscription_types + }` const { name } = user const firstName = name.split(' ')[0] @@ -490,7 +479,7 @@ export const sendInterestingMarketsEmail = async ( 'interesting-markets', { name: firstName, - unsubscribeLink: unsubscribeUrl, + unsubscribeUrl, question1Title: contractsToSend[0].question, question1Link: contractUrl(contractsToSend[0]), diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index f31674a1..7878e410 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' -import { sendMarketCloseEmail } from './emails' import { createNotification } from './create-notification' export const marketCloseNotifications = functions @@ -56,7 +55,6 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await sendMarketCloseEmail(user, privateUser, contract) await createNotification( contract.id, 'contract', diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a36a8bca..a46420bc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,15 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { compact } from 'lodash' import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' -import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { - createCommentOrAnswerOrUpdatedContractNotification, - filterUserIdsForOnlyFollowerIds, -} from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) - const relatedSourceType = comment.replyToCommentId - ? 'comment' - : comment.answerOutcome + const repliedToType = answer ? 'answer' + : comment.replyToCommentId + ? 'comment' : undefined const repliedUserId = comment.replyToCommentId @@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, + repliedToType, + repliedToId: comment.replyToCommentId || answer?.id, + repliedToContent: answer ? answer.text : undefined, repliedUserId, taggedUserIds: compact(parseMentions(comment.content)), } ) - - const recipientUserIds = await filterUserIdsForOnlyFollowerIds( - uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId), - contractId - ) - - await Promise.all( - recipientUserIds.map((userId) => - sendNewCommentEmail( - userId, - commentCreator, - contract, - comment, - bet, - answer?.text, - answer?.id - ) - ) - ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d7ecd56e..2972a305 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract - if (previousValue.isResolved !== contract.isResolved) { - let resolutionText = contract.resolution ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { - const answerText = contract.answers.find( - (answer) => answer.id === contract.resolution - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && contract.resolutionProbability) - resolutionText = `${contract.resolutionProbability}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && contract.resolutionValue) - resolutionText = `${contract.resolutionValue}` - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - contractUpdater, - eventId, - resolutionText, - contract - ) - } else if ( + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question ) { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..015ac72f 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -8,10 +8,8 @@ import { MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' -import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' -import { sendMarketResolutionEmail } from './emails' import { getLoanPayouts, getPayouts, @@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' -import { floatingEqual } from '../../common/util/math' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' const bodySchema = z.object({ contractId: z.string(), @@ -163,15 +161,45 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - await sendResolutionEmails( - bets, - userPayoutsWithoutLoans, + const userInvestments = mapValues( + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested + ) + let resolutionText = outcome ?? contract.question + if (contract.outcomeType === 'FREE_RESPONSE') { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && value) resolutionText = `${value}` + } + + // TODO: this actually may be too slow to complete with a ton of users to notify? + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'resolved', creator, - creatorPayout, + contract.id + '-resolution', + resolutionText, contract, - outcome, - resolutionProbability, - resolutions + undefined, + { + bets, + userInvestments, + userPayouts: userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions, + } ) return updatedContract @@ -189,51 +217,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { .then(() => ({ status: 'success' })) } -const sendResolutionEmails = async ( - bets: Bet[], - userPayouts: { [userId: string]: number }, - creator: User, - creatorPayout: number, - contract: Contract, - outcome: string, - resolutionProbability?: number, - resolutions?: { [outcome: string]: number } -) => { - const investedByUser = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets).invested - ) - const investedUsers = Object.keys(investedByUser).filter( - (userId) => !floatingEqual(investedByUser[userId], 0) - ) - - const nonWinners = difference(investedUsers, Object.keys(userPayouts)) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ].map(([userId, payout]) => ({ - userId, - investment: investedByUser[userId] ?? 0, - payout, - })) - - await Promise.all( - emailPayouts.map(({ userId, investment, payout }) => - sendMarketResolutionEmail( - userId, - investment, - payout, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - ) - ) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts new file mode 100644 index 00000000..a6bd1a0b --- /dev/null +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -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()) diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index acce446e..f9b8c3a1 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { PrivateUser, User } from 'common/user' +import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' const firestore = admin.firestore() @@ -21,6 +21,7 @@ async function main() { id: user.id, email, username, + notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx deleted file mode 100644 index 7ee27fb5..00000000 --- a/web/components/NotificationSettings.tsx +++ /dev/null @@ -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('all') - const [emailNotificationSettings, setEmailNotificationSettings] = - useState('all') - const [privateUser, setPrivateUser] = useState(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 - } - - function NotificationSettingLine(props: { - label: string | React.ReactNode - highlight: boolean - onClick?: () => void - }) { - const { label, highlight, onClick } = props - return ( - - {highlight ? : } - {label} - - ) - } - - return ( -
-
In App Notifications
- - changeInAppNotificationSettings( - choice as notification_subscribe_types - ) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- - You will receive notifications for these general events: - - - - You will receive new comment, answer, & resolution notifications on - questions: - - - That you watch - you - auto-watch questions if: - - } - onClick={() => setShowModal(true)} - /> - - • You create it - • You bet, comment on, or answer it - • You add liquidity to it - - • 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 - - - - -
Email Notifications
- - changeEmailNotifications(choice as notification_subscribe_types) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- You will receive emails for: - - - - -
-
- - - ) -} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/watch-market-modal.tsx similarity index 74% rename from web/components/contract/follow-market-modal.tsx rename to web/components/contract/watch-market-modal.tsx index fb62ce9f..2fb9bc00 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline' import React from 'react' import clsx from 'clsx' -export const FollowMarketModal = (props: { +export const WatchMarketModal = (props: { open: boolean setOpen: (b: boolean) => void title?: string @@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
• What is watching? - You can receive notifications on questions you're interested in by + You'll receive notifications on markets by betting, commenting, or clicking the • What types of notifications will I receive? - You'll receive in-app notifications for new comments, answers, and - updates to the question. + You'll receive notifications for new comments, answers, and updates + to the question. See the notifications settings pages to customize + which types of notifications you receive on watched markets. diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 332b044a..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' import { useState } from 'react' import { Col } from 'web/components/layout/col' @@ -65,7 +65,7 @@ export const FollowMarketButton = (props: { Watch )} - + } + + const emailsEnabled: Array = [ + 'all_comments_on_watched_markets', + 'all_replies_to_my_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + + '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 = [ + 'trending_markets', + 'profit_loss_updates', + 'onboarding_flow', + 'thank_you_for_purchases', + ] + + type sectionData = { + label: string + subscriptionTypeToDescription: { + [key in keyof Partial]: 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 ( + + + + {description} + + + {!browserDisabled.includes(key) && ( + + )} + {emailsEnabled.includes(key) && ( + + )} + + + + ) + } + + 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 ( + + setExpanded(!expanded)} + > + {icon} + {label} + + {expanded ? ( + + Hide + + ) : ( + + Show + + )} + + + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => + NotificationSettingLine( + value, + key as keyof notification_subscription_types, + getUsersSavedPreference( + key as keyof notification_subscription_types + ) + ) + )} + + + ) + } + + return ( +
+
+ + Notifications for Watched Markets + setShowWatchModal(true)} + /> + + {Section(, comments)} + {Section(, answers)} + {Section(, updates)} + {Section(, yourMarkets)} + + Balance Changes + + {Section(, bonuses)} + {Section(, otherBalances)} + + General + + {Section(, userInteractions)} + {Section(, generalOther)} + + + + ) +} diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx new file mode 100644 index 00000000..0e93c420 --- /dev/null +++ b/web/components/switch-setting.tsx @@ -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 ( + + + + + {label} + + + ) +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 473facd4..d8ce025e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' +import { PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' @@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) { if (!result.data) return undefined const notifications = result.data as Notification[] - return getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => !n.isSeenOnHref) - }, [privateUser.notificationPreferences, result.data]) + return notifications.filter((n) => !n.isSeenOnHref) + }, [result.data]) return notifications } @@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) { }) return notificationGroups } - -const lessPriorityReasons = [ - 'on_contract_with_users_comment', - 'on_contract_with_users_answer', - // Notifications not currently generated for users who've sold their shares - 'on_contract_with_users_shares_out', - // Not sure if users will want to see these w/ less: - // 'on_contract_with_users_shares_in', -] - -function getAppropriateNotifications( - notifications: Notification[], - notificationPreferences?: notification_subscribe_types -) { - if (notificationPreferences === 'all') return notifications - if (notificationPreferences === 'less') - return notifications.filter( - (n) => - n.reason && - // Show all contract notifications and any that aren't in the above list: - (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) - ) - if (notificationPreferences === 'none') return [] - - return notifications -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d10812bf..57e13fb9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ -import { Tabs } from 'web/components/layout/tabs' +import { ControlledTabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' -import Router from 'next/router' +import Router, { useRouter } from 'next/router' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -40,7 +40,7 @@ import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' import { SiteLink } from 'web/components/site-link' -import { NotificationSettings } from 'web/components/NotificationSettings' +import { NotificationSettings } from 'web/components/notification-settings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { UserLink } from 'web/components/user-link' @@ -56,24 +56,49 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50' export default function Notifications() { const privateUser = usePrivateUser() + const router = useRouter() + const [navigateToSection, setNavigateToSection] = useState() + const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { if (privateUser === null) Router.push('/') }) + useEffect(() => { + const query = { ...router.query } + if (query.section) { + setNavigateToSection(query.section as string) + setActiveIndex(1) + } + }, [router.query]) + return (
<SEO title="Notifications" description="Manifold user notifications" /> - {privateUser && ( + {privateUser && router.isReady && ( <div> - <Tabs + <ControlledTabs currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} - defaultIndex={0} + activeIndex={activeIndex} + onClick={(title, i) => { + router.replace( + { + query: { + ...router.query, + tab: title.toLowerCase(), + section: '', + }, + }, + undefined, + { shallow: true } + ) + setActiveIndex(i) + }} tabs={[ { title: 'Notifications', @@ -82,9 +107,9 @@ export default function Notifications() { { title: 'Settings', content: ( - <div className={''}> - <NotificationSettings /> - </div> + <NotificationSettings + navigateToSection={navigateToSection} + /> ), }, ]} @@ -992,6 +1017,7 @@ function getReasonForShowingNotification( ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string + // TODO: we could leave out this switch and just use the reason field now that they have more information switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') @@ -1003,7 +1029,7 @@ function getReasonForShowingNotification( else reasonText = justSummary ? `commented` : `commented on` break case 'contract': - if (reason === 'you_follow_user') + if (reason === 'contract_from_followed_user') reasonText = justSummary ? 'asked the question' : 'asked' else if (sourceUpdateType === 'resolved') reasonText = justSummary ? `resolved the question` : `resolved` @@ -1011,7 +1037,8 @@ function getReasonForShowingNotification( else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` else reasonText = `answered` break case 'follow':
+ +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ + - - - -
- + - -
+ - - - + " target="_blank">click here to manage your notifications. +

+ + + + + +
-
+
-

- This e-mail has been sent to {{name}}, - +

+ This e-mail has been sent to {{name}}, + click here to unsubscribe. -

-
-
+
+
+
- - -
-
- - - - +
+ + +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html index c4ad7baa..7ac72d0a 100644 --- a/functions/src/email-templates/thank-you.html +++ b/functions/src/email-templates/thank-you.html @@ -214,10 +214,12 @@

This e-mail has been sent - to {{name}}, click here to - unsubscribe.

+ to {{name}}, + click here to manage your notifications. +