From 4398fa9bda37ec21d81023732c028d353b72c703 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 09:54:51 -0600 Subject: [PATCH] Add new market from followed user email notification --- functions/src/create-notification.ts | 211 ++++++----- .../new-market-from-followed-user.html | 354 ++++++++++++++++++ functions/src/emails.ts | 34 ++ functions/src/on-create-contract.ts | 10 +- web/components/notification-settings.tsx | 5 +- web/pages/notifications.tsx | 95 ++--- 6 files changed, 567 insertions(+), 142 deletions(-) create mode 100644 functions/src/email-templates/new-market-from-followed-user.html diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 2815655f..84edf715 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -22,7 +22,9 @@ import { sendMarketResolutionEmail, sendNewAnswerEmail, sendNewCommentEmail, + sendNewFollowedMarketEmail, } from './emails' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -103,51 +105,14 @@ export const createNotification = async ( 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: recipients_to_reason_texts - ) => { - const followers = await firestore - .collectionGroup('follows') - .where('userId', '==', sourceUser.id) - .get() - - followers.docs.forEach((doc) => { - const followerUserId = doc.ref.parent.parent?.id - if ( - followerUserId && - shouldReceiveNotification(followerUserId, userToReasonTexts) - ) { - userToReasonTexts[followerUserId] = { - reason: 'contract_from_followed_user', - } - } - }) - } - - const notifyTaggedUsers = ( - userToReasonTexts: recipients_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - if (id && shouldReceiveNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) - } - // The following functions modify the userToReasonTexts object in place. const userToReasonTexts: recipients_to_reason_texts = {} @@ -157,15 +122,6 @@ export const createNotification = async ( reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'created' && - sourceContract - ) { - if (sourceContract.visibility === 'public') - await notifyUsersFollowers(userToReasonTexts) - await notifyTaggedUsers(userToReasonTexts, recipients ?? []) - return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && @@ -283,52 +239,57 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( reason ) + // Browser notifications if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) browserRecipientIdsList.push(userId) } - if (sendToEmail && !emailRecipientIdsList.includes(userId)) { - if (sourceType === 'comment') { - const { repliedToType, repliedToAnswerText, repliedToId, bet } = - repliedUsersInfo?.[userId] ?? {} - // 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, - bet, - repliedToAnswerText, - 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 + + // Emails notifications + if (!sendToEmail || emailRecipientIdsList.includes(userId)) return + if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} + // 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, + bet, + repliedToAnswerText, + repliedToType === 'answer' ? repliedToId : undefined + ) + emailRecipientIdsList.push(userId) + } else if (sourceType === 'answer') { + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + emailRecipientIdsList.push(userId) + } 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 ) - await sendMarketResolutionEmail( - reason, - privateUser, - resolutionData.userInvestments[userId] ?? 0, - resolutionData.userPayouts[userId] ?? 0, - sourceUser, - resolutionData.creatorPayout, - sourceContract, - resolutionData.outcome, - resolutionData.resolutionProbability, - resolutionData.resolutions - ) emailRecipientIdsList.push(userId) } } @@ -852,3 +813,79 @@ export const createUniqueBettorBonusNotification = async ( // TODO send email notification } + +export const createNewContractNotification = async ( + contractCreator: User, + contract: Contract, + idempotencyKey: string, + text: string, + mentionedUserIds: string[] +) => { + if (contract.visibility !== 'public') return + + const sendNotificationsIfSettingsAllow = async ( + userId: string, + reason: notification_reason_types + ) => { + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'created', + sourceUserName: contractCreator.name, + sourceUserUsername: contractCreator.username, + sourceUserAvatarUrl: contractCreator.avatarUrl, + sourceText: text, + sourceSlug: contract.slug, + sourceTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) + } + if (!sendToEmail) return + if (reason === 'contract_from_followed_user') + await sendNewFollowedMarketEmail(reason, userId, privateUser, contract) + } + const followersSnapshot = await firestore + .collectionGroup('follows') + .where('userId', '==', contractCreator.id) + .get() + + const followerUserIds = filterDefined( + followersSnapshot.docs.map((doc) => { + const followerUserId = doc.ref.parent.parent?.id + return followerUserId && followerUserId != contractCreator.id + ? followerUserId + : undefined + }) + ) + + // As it is coded now, the tag notification usurps the new contract notification + // It'd be easy to append the reason to the eventId if desired + for (const followerUserId of followerUserIds) { + await sendNotificationsIfSettingsAllow( + followerUserId, + 'contract_from_followed_user' + ) + } + for (const mentionedUserId of mentionedUserIds) { + await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') + } +} diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html new file mode 100644 index 00000000..877d554f --- /dev/null +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -0,0 +1,354 @@ + + + + + New market from {{creatorName}} + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ {{creatorName}}, (who you're following) just created a new market, check it out!

+
+
+
+ + {{questionTitle}} + +
+ + + + + +
+ + View market + +
+ +
+
+ +
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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

+
+
+
+
+ +
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d1387ef9..da6a5b41 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -510,3 +510,37 @@ function contractUrl(contract: Contract) { function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } + +export const sendNewFollowedMarketEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + + return await sendTemplateEmail( + privateUser.email, + `${creatorName} asked ${contract.question}`, + 'new-market-from-followed-user', + { + name: firstName, + creatorName, + unsubscribeUrl, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionImgSrc: imageSourceUrl(contract), + }, + { + from: `${creatorName} on Manifold `, + } + ) +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index d9826f6c..b613142b 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -21,13 +21,11 @@ export const onCreateContract = functions const mentioned = parseMentions(desc) await addUserToContractFollowers(contract.id, contractCreator.id) - await createNotification( - contract.id, - 'contract', - 'created', + await createNewContractNotification( contractCreator, + contract, eventId, richTextToString(desc), - { contract, recipients: mentioned } + mentioned ) }) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c319bf32..65804991 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -58,9 +58,9 @@ export function NotificationSettings(props: { 'onboarding_flow', 'thank_you_for_purchases', + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', // TODO: add these - 'tagged_user', - // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', // 'on_new_follow', @@ -90,6 +90,7 @@ export function NotificationSettings(props: { 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`, + // TODO: combine these two all_replies_to_my_comments_on_watched_markets: 'Only replies to your comments', all_replies_to_my_answers_on_watched_markets: diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a4c25ed3..7ebc473b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1031,52 +1031,53 @@ 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') - reasonText = justSummary ? 'replied' : 'replied to you on' - else if (reason === 'tagged_user') - reasonText = justSummary ? 'tagged you' : 'tagged you on' - else if (reason === 'reply_to_users_comment') - reasonText = justSummary ? 'replied' : 'replied to you on' - else reasonText = justSummary ? `commented` : `commented on` - break - case 'contract': - if (reason === 'contract_from_followed_user') - reasonText = justSummary ? 'asked the question' : 'asked' - else if (sourceUpdateType === 'resolved') - reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` - else reasonText = justSummary ? 'updated the question' : `updated` - break - case 'answer': - if (reason === 'answer_on_your_contract') - reasonText = `answered your question ` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added a subsidy to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - case 'bet': - reasonText = 'bet against you' - break - case 'challenge': - reasonText = 'accepted your challenge' - break - default: - reasonText = '' - } + if (reason === 'tagged_user') + reasonText = justSummary ? 'tagged you' : 'tagged you on' + else + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = justSummary ? 'replied' : 'replied to you on' + else if (reason === 'reply_to_users_comment') + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` + break + case 'contract': + if (reason === 'contract_from_followed_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` + break + case 'answer': + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added a subsidy to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bet': + reasonText = 'bet against you' + break + case 'challenge': + reasonText = 'accepted your challenge' + break + default: + reasonText = '' + } return reasonText }