diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..871bc821 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,18 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 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 0e333278..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 @@ -34,7 +36,7 @@ export type User = { followerCountCached: number followedCategories?: string[] - homeSections?: { visible: string[]; hidden: string[] } + homeSections?: string[] referredByUserId?: string referredByContractId?: string @@ -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/common/util/parse.ts b/common/util/parse.ts index 4fac3225..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' +import { find } from 'linkifyjs' import { uniq } from 'lodash' +/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +export function getUrl(text: string) { + const results = find(text, 'url') + return results.length ? results[0].href : null +} + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const matches = (text.match(regex) || []).map((match) => 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..84edf715 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,27 @@ 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, + sendNewFollowedMarketEmail, +} from './emails' +import { filterDefined } from '../../common/util/array' 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 +47,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 +57,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,212 +93,232 @@ export const createNotification = async ( sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } - - const notifyUsersFollowers = async ( - userToReasonTexts: user_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 && - shouldGetNotification(followerUserId, userToReasonTexts) - ) { - userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', - } } - }) - } - const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, - followedUserId: string - ) => { - if (shouldGetNotification(followedUserId, userToReasonTexts)) - userToReasonTexts[followedUserId] = { - reason: 'on_new_follow', + 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 === 'subsidized_your_market') { + // TODO: send email to creator of market that was subsidized + } else if (reason === 'on_new_follow') { + // TODO: send email to user who was followed } + } } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - if (id && shouldGetNotification(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)) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'created' && - sourceContract - ) { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (shouldReceiveNotification(recipients[0], userToReasonTexts)) + userToReasonTexts[recipients[0]] = { + reason: 'on_new_follow', + } + 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 type replied_users_info = { + [key: string]: { + repliedToType: 'comment' | 'answer' + repliedToAnswerText: string | undefined + repliedToId: string | undefined + bet: Bet | undefined + } } 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 - repliedUserId?: string - taggedUserIds?: string[] + repliedUsersInfo: replied_users_info + 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 { repliedUsersInfo, taggedUserIds } = 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 browserRecipientIdsList: string[] = [] + const emailRecipientIdsList: 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 + ) + 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', - } + // Browser notifications + if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { + await createBrowserNotification(userId, reason) + browserRecipientIdsList.push(userId) + } + + // 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 + ) + emailRecipientIdsList.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 +326,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 +350,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 +382,77 @@ 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' && repliedUsersInfo) + await Promise.all( + Object.keys(repliedUsersInfo).map((userId) => + sendNotificationsIfSettingsPermit( + userId, + repliedUsersInfo[userId].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 +464,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 +496,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 +509,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 +542,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 +554,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 +599,8 @@ export const createReferralNotification = async ( : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } export const createLoanIncomeNotification = async ( @@ -582,6 +608,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 +646,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 +687,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 +730,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 +764,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 +776,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 +810,82 @@ export const createUniqueBettorBonusNotification = async ( sourceContractCreatorUsername: contract.creatorUsername, } return await notificationRef.set(removeUndefinedProps(notification)) + + // 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/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-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html new file mode 100644 index 00000000..ff5f541f --- /dev/null +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -0,0 +1,491 @@ + + + + + + + Market resolved + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ + Manifold Markets + +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

+
+ + + + + + + +
+ Dear {{name}}, +
+
+ A market you were following has been resolved! +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file 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/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/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..da6a5b41 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,17 +52,13 @@ 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) - : formatMoney(investment) + const correctedInvestment = + Number.isNaN(investment) || investment < 0 ? 0 : investment + const displayedInvestment = formatMoney(correctedInvestment) const displayedPayout = formatMoney(payout) @@ -85,7 +80,7 @@ export const sendMarketResolutionEmail = async ( return await sendTemplateEmail( privateUser.email, subject, - 'market-resolved', + correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved', templateData ) } @@ -154,11 +149,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 +162,7 @@ export const sendWelcomeEmail = async ( 'welcome', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -217,23 +213,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 +246,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 +278,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 +297,7 @@ export const sendThankYouEmail = async ( 'thank-you', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -307,16 +306,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 +322,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 +339,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 +370,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 +381,7 @@ export const sendNewCommentEmail = async ( answerNumber, commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -412,7 +402,7 @@ export const sendNewCommentEmail = async ( { commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -423,29 +413,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 +459,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 +478,7 @@ export const sendInterestingMarketsEmail = async ( 'interesting-markets', { name: firstName, - unsubscribeLink: unsubscribeUrl, + unsubscribeUrl, question1Title: contractsToSend[0].question, question1Link: contractUrl(contractsToSend[0]), @@ -522,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/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..65e32dca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,14 +1,13 @@ 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, + replied_users_info, } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,16 +76,46 @@ 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 ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const mentionedUsers = compact(parseMentions(comment.content)) + const repliedUsers: replied_users_info = {} + + // The parent of the reply chain could be a comment or an answer + if (repliedUserId && repliedToType) + repliedUsers[repliedUserId] = { + repliedToType, + repliedToAnswerText: answer ? answer.text : undefined, + repliedToId: comment.replyToCommentId || answer?.id, + bet: bet, + } + + const commentsInSameReplyChain = comments.filter((c) => + repliedToType === 'answer' + ? c.answerOutcome === answer?.id + : repliedToType === 'comment' + ? c.replyToCommentId === comment.replyToCommentId + : false + ) + // The rest of the children in the chain are always comments + commentsInSameReplyChain.forEach((c) => { + if (c.userId !== comment.userId && c.userId !== repliedUserId) { + repliedUsers[c.userId] = { + repliedToType: 'comment', + repliedToAnswerText: undefined, + repliedToId: c.id, + bet: undefined, + } + } + }) await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', @@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, - repliedUserId, - taggedUserIds: compact(parseMentions(comment.content)), + repliedUsersInfo: repliedUsers, + taggedUserIds: mentionedUsers, } ) - - 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-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/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..b867b609 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,48 @@ 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' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { + 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 +220,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/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..1de8056c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues(firestore.collection('users')), + getValues(firestore.collection('contracts')), + getValues(firestore.collectionGroup('bets')), + getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -41,6 +57,7 @@ export async function updateMetricsCore() { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contractUpdates = contracts .filter((contract) => contract.id) .map((contract) => { @@ -162,4 +179,48 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + try { + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds + .map((e) => contractsById[e.contractId]) + .filter((e) => e !== undefined) as Contract[] + const bets = groupContracts.map((e) => { + if (e != null && e.id in betsByContract) { + return betsByContract[e.id] ?? [] + } else { + return [] + } + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + await writeAsync(firestore, groupUpdates) + } catch (e) { + console.log('Error While Updating Group Leaderboards', e) + } } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } 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/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 5811403f..444c5701 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -30,14 +31,15 @@ export function AnswersPanel(props: { const { contract } = props const { creatorId, resolution, resolutions, totalBets, outcomeType } = contract + const [showAllAnswers, setShowAllAnswers] = useState(false) + + const answers = (useAnswers(contract.id) ?? contract.answers).filter( + (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' + ) + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) - const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( - answers.filter( - (answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - totalBets[answer.id] > 0.000000001 - ), + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) ) @@ -127,6 +129,17 @@ export function AnswersPanel(props: { ))} + + {hasZeroBetAnswers && !showAllAnswers && ( + + )} + )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 7e20e92e..58f55327 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (existingAnswer) { setAnswerError( existingAnswer - ? `"${existingAnswer.text}" already exists as an answer` + ? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.` : '' ) return @@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => { }[level] ?? '' return (
{text}
diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index ae02e3ea..646d30fe 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -7,25 +7,19 @@ import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' import { useMemberGroups } from 'web/hooks/use-group' import { filterDefined } from 'common/util/array' -import { keyBy } from 'lodash' +import { isArray, keyBy } from 'lodash' import { User } from 'common/user' import { Group } from 'common/group' export function ArrangeHome(props: { user: User | null | undefined - homeSections: { visible: string[]; hidden: string[] } - setHomeSections: (homeSections: { - visible: string[] - hidden: string[] - }) => void + homeSections: string[] + setHomeSections: (sections: string[]) => void }) { const { user, homeSections, setHomeSections } = props const groups = useMemberGroups(user?.id) ?? [] - const { itemsById, visibleItems, hiddenItems } = getHomeItems( - groups, - homeSections - ) + const { itemsById, sections } = getHomeItems(groups, homeSections) return ( item.id), - hidden: hiddenItems.map((item) => item.id), - } + const newHomeSections = sections.map((section) => section.id) - const sourceSection = source.droppableId as 'visible' | 'hidden' - newHomeSections[sourceSection].splice(source.index, 1) - - const destSection = destination.droppableId as 'visible' | 'hidden' - newHomeSections[destSection].splice(destination.index, 0, item.id) + newHomeSections.splice(source.index, 1) + newHomeSections.splice(destination.index, 0, item.id) setHomeSections(newHomeSections) }} > - - - + + ) @@ -64,16 +51,13 @@ function DraggableList(props: { const { title, items } = props return ( - {(provided, snapshot) => ( + {(provided) => (
- + {items.map((item, index) => ( {(provided, snapshot) => ( @@ -82,16 +66,13 @@ function DraggableList(props: { {...provided.draggableProps} {...provided.dragHandleProps} style={provided.draggableProps.style} - className={clsx( - 'flex flex-row items-center gap-4 rounded bg-gray-50 p-2', - snapshot.isDragging && 'z-[9000] bg-gray-300' - )} > - @@ -103,15 +84,36 @@ function DraggableList(props: { ) } -export const getHomeItems = ( - groups: Group[], - homeSections: { visible: string[]; hidden: string[] } -) => { +const SectionItem = (props: { + item: { id: string; label: string } + className?: string +}) => { + const { item, className } = props + + return ( +
+
+ ) +} + +export const getHomeItems = (groups: Group[], sections: string[]) => { + // Accommodate old home sections. + if (!isArray(sections)) sections = [] + const items = [ + { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, - { label: 'Newest', id: 'newest' }, - { label: 'Close date', id: 'close-date' }, - { label: 'Your trades', id: 'your-bets' }, + { label: 'New for you', id: 'newest' }, ...groups.map((g) => ({ label: g.name, id: g.id, @@ -119,23 +121,13 @@ export const getHomeItems = ( ] const itemsById = keyBy(items, 'id') - const { visible, hidden } = homeSections + const sectionItems = filterDefined(sections.map((id) => itemsById[id])) - const [visibleItems, hiddenItems] = [ - filterDefined(visible.map((id) => itemsById[id])), - filterDefined(hidden.map((id) => itemsById[id])), - ] - - // Add unmentioned items to the visible list. - visibleItems.push( - ...items.filter( - (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) - ) - ) + // Add unmentioned items to the end. + sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) return { - visibleItems, - hiddenItems, + sections: sectionItems, itemsById, } } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 78934389..00db5cb4 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector' import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { isAndroid, isIOS } from 'web/lib/util/device' import { WarningConfirmationButton } from './warning-confirmation-button' +import { MarketIntroPanel } from './market-intro-panel' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -90,10 +91,7 @@ export function BetPanel(props: { /> ) : ( - <> - - - + )} diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f973d260..49216b88 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -2,74 +2,69 @@ import clsx from 'clsx' import { contractPath } from 'web/lib/firebase/contracts' import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { linkClass, SiteLink } from '../site-link' +import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' -import { useState } from 'react' +import { LoadingIndicator } from '../loading-indicator' -export function ProbChangeTable(props: { userId: string | undefined }) { - const { userId } = props +export function ProbChangeTable(props: { + changes: + | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } + | undefined +}) { + const { changes } = props - const changes = useProbChanges(userId ?? '') - const [expanded, setExpanded] = useState(false) - - if (!changes) { - return null - } - - const count = expanded ? 16 : 4 + if (!changes) return const { positiveChanges, negativeChanges } = changes - const filteredPositiveChanges = positiveChanges.slice(0, count / 2) - const filteredNegativeChanges = negativeChanges.slice(0, count / 2) - const filteredChanges = [ - ...filteredPositiveChanges, - ...filteredNegativeChanges, - ] + + const threshold = 0.075 + const countOverThreshold = Math.max( + positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1, + negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1 + ) + const maxRows = Math.min(positiveChanges.length, negativeChanges.length) + const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) + + const filteredPositiveChanges = positiveChanges.slice(0, rows) + const filteredNegativeChanges = negativeChanges.slice(0, rows) + + if (rows === 0) return
None
return ( -
- - - {filteredChanges.slice(0, count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - - - {filteredChanges.slice(count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - + + + {filteredPositiveChanges.map((contract) => ( + + + + {contract.question} + + + ))} + + + {filteredNegativeChanges.map((contract) => ( + + + + {contract.question} + + + ))} -
setExpanded(!expanded)} - > - {expanded ? 'Show less' : 'Show more'} -
) } 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/editor.tsx b/web/components/editor.tsx index bb947579..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { useEditor, + BubbleMenu, EditorContent, JSONContent, Content, @@ -24,13 +25,19 @@ import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' import { + CheckIcon, CodeIcon, PhotographIcon, PresentationChartLineIcon, + TrashIcon, } from '@heroicons/react/solid' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' import { Tooltip } from './tooltip' +import BoldIcon from 'web/lib/icons/bold-icon' +import ItalicIcon from 'web/lib/icons/italic-icon' +import LinkIcon from 'web/lib/icons/link-icon' +import { getUrl } from 'common/util/parse' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -141,6 +148,66 @@ function isValidIframe(text: string) { return /^$/.test(text) } +function FloatingMenu(props: { editor: Editor | null }) { + const { editor } = props + + const [url, setUrl] = useState(null) + + if (!editor) return null + + // current selection + const isBold = editor.isActive('bold') + const isItalic = editor.isActive('italic') + const isLink = editor.isActive('link') + + const setLink = () => { + const href = url && getUrl(url) + if (href) { + editor.chain().focus().extendMarkRange('link').setLink({ href }).run() + } + } + + const unsetLink = () => editor.chain().focus().unsetLink().run() + + return ( + + {url === null ? ( + <> + + + + + ) : ( + <> + setUrl(e.target.value)} + /> + + + + )} + + ) +} + export function TextEditor(props: { editor: Editor | null upload: ReturnType @@ -155,6 +222,7 @@ export function TextEditor(props: { {/* hide placeholder when focused */}
+ {/* Toolbar, with buttons for images and embeds */}
diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index a3e9f35a..f896ddb5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -14,9 +14,7 @@ import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract } from 'web/lib/firebase/comments' -import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' -import { getProbability } from 'common/calculate' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' @@ -301,74 +299,14 @@ export function ContractCommentInput(props: { const { id } = mostRecentCommentableBet || { id: undefined } return ( -
- - - - ) -} - -function CommentBetArea(props: { - betsByCurrentUser: Bet[] - contract: Contract - commentsByCurrentUser: ContractComment[] - parentAnswerOutcome?: string - user?: User | null - className?: string - mostRecentCommentableBet?: Bet -}) { - const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props - - const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( - contract, - Date.now(), - betsByCurrentUser - ) - - const isNumeric = contract.outcomeType === 'NUMERIC' - - return ( - -
- {mostRecentCommentableBet && ( - - )} - {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( - <> - {"You're"} - - - )} -
-
+ ) } 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 )} -
- +
Play-money predictions
+ + + +
+ Manifold Markets is a play-money prediction market platform where you + can forecast anything. +
+ + + + ) +} diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx new file mode 100644 index 00000000..65804991 --- /dev/null +++ b/web/components/notification-settings.tsx @@ -0,0 +1,320 @@ +import { usePrivateUser } from 'web/hooks/use-user' +import React, { ReactNode, useEffect, useState } from 'react' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { + notification_subscription_types, + notification_destination_types, +} from 'common/user' +import { updatePrivateUser } from 'web/lib/firebase/users' +import { Col } from 'web/components/layout/col' +import { + CashIcon, + ChatIcon, + ChevronDownIcon, + ChevronUpIcon, + CurrencyDollarIcon, + InboxInIcon, + InformationCircleIcon, + LightBulbIcon, + TrendingUpIcon, + UserIcon, + UsersIcon, +} from '@heroicons/react/outline' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' +import { filterDefined } from 'common/util/array' +import toast from 'react-hot-toast' +import { SwitchSetting } from 'web/components/switch-setting' + +export function NotificationSettings(props: { + navigateToSection: string | undefined +}) { + const { navigateToSection } = props + const privateUser = usePrivateUser() + const [showWatchModal, setShowWatchModal] = useState(false) + + if (!privateUser || !privateUser.notificationSubscriptionTypes) { + return + } + + 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', + + 'trending_markets', + 'onboarding_flow', + 'thank_you_for_purchases', + + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', + // TODO: add these + // 'referral_bonuses', + // 'unique_bettors_on_your_contract', + // 'on_new_follow', + // 'profit_loss_updates', + // 'tips_on_your_markets', + // 'tips_on_your_comments', + // maybe the following? + // '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`, + // TODO: combine these two + all_replies_to_my_comments_on_watched_markets: + 'Only replies to your comments', + all_replies_to_my_answers_on_watched_markets: + 'Only replies to your answers', + // comments_by_followed_users_on_watched_markets: 'By followed users', + }, + } + + const answers: sectionData = { + label: 'New Answers', + subscriptionTypeToDescription: { + all_answers_on_watched_markets: 'All new answers', + all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // answers_by_followed_users_on_watched_markets: 'By followed users', + // answers_by_market_creator_on_watched_markets: 'By market creator', + }, + } + 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/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 7a372d9a..f27460d9 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,7 +24,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll('groups') export const groupMembers = (groupId: string) => @@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } diff --git a/web/lib/icons/bold-icon.tsx b/web/lib/icons/bold-icon.tsx new file mode 100644 index 00000000..f4fec497 --- /dev/null +++ b/web/lib/icons/bold-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function BoldIcon(props: React.SVGProps) { + return ( + + + + + ) +} diff --git a/web/lib/icons/italic-icon.tsx b/web/lib/icons/italic-icon.tsx new file mode 100644 index 00000000..d412ed77 --- /dev/null +++ b/web/lib/icons/italic-icon.tsx @@ -0,0 +1,21 @@ +// from Feather: https://feathericons.com/ +export default function ItalicIcon(props: React.SVGProps) { + return ( + + + + + + ) +} diff --git a/web/lib/icons/link-icon.tsx b/web/lib/icons/link-icon.tsx new file mode 100644 index 00000000..6323344c --- /dev/null +++ b/web/lib/icons/link-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function LinkIcon(props: React.SVGProps) { + return ( + + + + + ) +} diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index 2cba3f19..2ed9d2dd 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -16,14 +16,9 @@ export default function Home() { useTracking('edit home') - const [homeSections, setHomeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) + const [homeSections, setHomeSections] = useState(user?.homeSections ?? []) - const updateHomeSections = (newHomeSections: { - visible: string[] - hidden: string[] - }) => { + const updateHomeSections = (newHomeSections: string[]) => { if (!user) return updateUser(user.id, { homeSections: newHomeSections }) setHomeSections(newHomeSections) @@ -31,7 +26,7 @@ export default function Home() { return ( - + <DoneButton /> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 90b4f888..08f502b6 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import Router from 'next/router' import { PencilIcon, @@ -28,6 +28,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { calculatePortfolioProfit } from 'common/calculate-metrics' import { formatMoney } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' const Home = () => { const user = useUser() @@ -38,10 +39,7 @@ const Home = () => { const groups = useMemberGroups(user?.id) ?? [] - const [homeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) - const { visibleItems } = getHomeItems(groups, homeSections) + const { sections } = getHomeItems(groups, user?.homeSections ?? []) return ( <Page> @@ -54,29 +52,19 @@ const Home = () => { <DailyProfitAndBalance userId={user?.id} /> - <div className="text-xl text-gray-800">Daily movers</div> - <ProbChangeTable userId={user?.id} /> - - {visibleItems.map((item) => { + {sections.map((item) => { const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your trades'} - sort={'newest'} - user={user} - yourBets - /> - ) + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user?.id} /> } const sort = SORTS.find((sort) => sort.value === id) if (sort) return ( <SearchSection key={id} - label={sort.label} + label={sort.value === 'newest' ? 'New for you' : sort.label} sort={sort.value} + followed={sort.value === 'newest'} user={user} /> ) @@ -103,11 +91,12 @@ const Home = () => { function SearchSection(props: { label: string - user: User | null | undefined + user: User | null | undefined | undefined sort: Sort yourBets?: boolean + followed?: boolean }) { - const { label, user, sort, yourBets } = props + const { label, user, sort, yourBets, followed } = props const href = `/home?s=${sort}` return ( @@ -122,7 +111,13 @@ function SearchSection(props: { <ContractSearch user={user} defaultSort={sort} - additionalFilter={yourBets ? { yourBets: true } : { followed: true }} + additionalFilter={ + yourBets + ? { yourBets: true } + : followed + ? { followed: true } + : undefined + } noControls maxResults={6} persistPrefix={`experimental-home-${sort}`} @@ -131,7 +126,10 @@ function SearchSection(props: { ) } -function GroupSection(props: { group: Group; user: User | null | undefined }) { +function GroupSection(props: { + group: Group + user: User | null | undefined | undefined +}) { const { group, user } = props return ( @@ -155,6 +153,24 @@ function GroupSection(props: { group: Group; user: User | null | undefined }) { ) } +function DailyMoversSection(props: { userId: string | null | undefined }) { + const { userId } = props + const changes = useProbChanges(userId ?? '') + + return ( + <Col className="gap-2"> + <SiteLink className="text-xl" href={'/daily-movers'}> + Daily movers{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + function EditButton(props: { className?: string }) { const { className } = props @@ -186,14 +202,14 @@ function DailyProfitAndBalance(props: { return ( <div className={clsx(className, 'text-lg')}> <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> - {profit >= 0 ? '+' : '-'} + {profit >= 0 && '+'} {formatMoney(profit)} </span>{' '} profit and{' '} <span className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} > - {balanceChange >= 0 ? '+' : '-'} + {balanceChange >= 0 && '+'} {formatMoney(balanceChange)} </span>{' '} balance today diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f124e225 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,33 +69,24 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise - // Only count unresolved markets - const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { - contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +96,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -132,39 +108,25 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { - contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { - contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { creator, topTraders, topCreators, suggestedFilter } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +137,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +148,25 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( <Col> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + <GroupLeaderboard + topUsers={topTraders} + title="🏅 Top traders" + header="Profit" + maxToShow={maxLeaderboardSize} + /> + <GroupLeaderboard + topUsers={topCreators} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxLeaderboardSize} + /> + </div> </Col> ) @@ -216,7 +185,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> </Col> ) @@ -233,7 +202,6 @@ export default function GroupPage(props: { const tabs = [ { - badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), @@ -312,9 +280,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +301,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,155 +367,37 @@ function GroupOverview(props: { /> </Col> )} - - <Col className={'mt-2'}> - <div className="mb-2 text-lg">Members</div> - <GroupMemberSearch members={members} group={group} /> - </Col> </Col> </> ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( - <div className={'relative'}> - <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> - </div> - ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( - <div> - <SearchBar setQuery={setQuery} /> - <Col className={'gap-2'}> - {matches.length > 0 && ( - <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> - )} - {matches.length > 25 && ( - <div className={'text-center'}> - And {matches.length - matchLimit} more... - </div> - )} - </Col> - </div> - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( <Leaderboard className="max-w-xl" - users={sortedUsers} + users={topUsers.map((t) => t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( - <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {members.length > 1 ? ( - <> - <SortedLeaderboard - users={members} - scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - <SortedLeaderboard - users={members} - scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - </> - ) : ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - </> - )} - </div> - </Col> - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -684,3 +534,15 @@ function JoinGroupButton(props: { </div> ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d10812bf..7ebc473b 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' @@ -26,6 +26,7 @@ import { import { NotificationGroup, useGroupedNotifications, + useUnseenGroupedNotification, } from 'web/hooks/use-notifications' import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' @@ -40,7 +41,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 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50' export default function Notifications() { const privateUser = usePrivateUser() + const router = useRouter() + const [navigateToSection, setNavigateToSection] = useState<string>() + const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { if (privateUser === null) Router.push('/') }) + useEffect(() => { + const query = { ...router.query } + if (query.tab === 'settings') { + setActiveIndex(1) + } + if (query.section) { + setNavigateToSection(query.section as string) + } + }, [router.query]) + return ( <Page> <div className={'px-2 pt-4 sm:px-4 lg:pt-0'}> <Title text={'Notifications'} className={'hidden md:block'} /> <SEO title="Notifications" description="Manifold user notifications" /> - {privateUser && ( + {privateUser && router.isReady && ( <div> - <Tabs + <ControlledTabs currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} - defaultIndex={0} + activeIndex={activeIndex} + onClick={(title, i) => { + router.replace( + { + query: { + ...router.query, + tab: title.toLowerCase(), + section: '', + }, + }, + undefined, + { shallow: true } + ) + setActiveIndex(i) + }} tabs={[ { title: 'Notifications', @@ -82,9 +110,9 @@ export default function Notifications() { { title: 'Settings', content: ( - <div className={''}> - <NotificationSettings /> - </div> + <NotificationSettings + navigateToSection={navigateToSection} + /> ), }, ]} @@ -128,16 +156,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props const [page, setPage] = useState(0) const allGroupedNotifications = useGroupedNotifications(privateUser) + const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser) const paginatedGroupedNotifications = useMemo(() => { if (!allGroupedNotifications) return const start = page * NOTIFICATIONS_PER_PAGE const end = start + NOTIFICATIONS_PER_PAGE const maxNotificationsToShow = allGroupedNotifications.slice(start, end) - const remainingNotification = allGroupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } + const local = safeLocalStorage() local?.setItem( 'notification-groups', @@ -146,6 +171,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) { return maxNotificationsToShow }, [allGroupedNotifications, page]) + // Set all notifications that don't fit on the first page to seen + useEffect(() => { + if ( + paginatedGroupedNotifications && + paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE + ) { + const allUnseenNotifications = unseenGroupedNotifications + ?.map((ng) => ng.notifications) + .flat() + allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications) + } + }, [paginatedGroupedNotifications, unseenGroupedNotifications]) + if (!paginatedGroupedNotifications || !allGroupedNotifications) return <LoadingIndicator /> @@ -992,51 +1030,54 @@ function getReasonForShowingNotification( ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string - 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 === 'you_follow_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 === 'on_users_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 = '' - } + // TODO: we could leave out this switch and just use the reason field now that they have more information + 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 }
+ +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ + - - - -
- + - -
+ - - - + " 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. +