Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
		
						commit
						325961fb5b
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -1,3 +1,6 @@ | |||
| import { notification_subscription_types, PrivateUser } from './user' | ||||
| import { DOMAIN } from './envs/constants' | ||||
| 
 | ||||
| export type Notification = { | ||||
|   id: string | ||||
|   userId: string | ||||
|  | @ -51,28 +54,106 @@ export type notification_source_update_types = | |||
|   | 'deleted' | ||||
|   | 'closed' | ||||
| 
 | ||||
| /* Optional - if possible use a keyof notification_subscription_types */ | ||||
| export type notification_reason_types = | ||||
|   | 'tagged_user' | ||||
|   | 'on_users_contract' | ||||
|   | 'on_contract_with_users_shares_in' | ||||
|   | 'on_contract_with_users_shares_out' | ||||
|   | 'on_contract_with_users_answer' | ||||
|   | 'on_contract_with_users_comment' | ||||
|   | 'reply_to_users_answer' | ||||
|   | 'reply_to_users_comment' | ||||
|   | 'on_new_follow' | ||||
|   | 'you_follow_user' | ||||
|   | 'added_you_to_group' | ||||
|   | 'contract_from_followed_user' | ||||
|   | 'you_referred_user' | ||||
|   | 'user_joined_to_bet_on_your_market' | ||||
|   | 'unique_bettors_on_your_contract' | ||||
|   | 'on_group_you_are_member_of' | ||||
|   | 'tip_received' | ||||
|   | 'bet_fill' | ||||
|   | 'user_joined_from_your_group_invite' | ||||
|   | 'challenge_accepted' | ||||
|   | 'betting_streak_incremented' | ||||
|   | 'loan_income' | ||||
|   | 'you_follow_contract' | ||||
|   | 'liked_your_contract' | ||||
|   | 'liked_and_tipped_your_contract' | ||||
|   | 'comment_on_your_contract' | ||||
|   | 'answer_on_your_contract' | ||||
|   | 'comment_on_contract_you_follow' | ||||
|   | 'answer_on_contract_you_follow' | ||||
|   | 'update_on_contract_you_follow' | ||||
|   | 'resolution_on_contract_you_follow' | ||||
|   | 'comment_on_contract_with_users_shares_in' | ||||
|   | 'answer_on_contract_with_users_shares_in' | ||||
|   | 'update_on_contract_with_users_shares_in' | ||||
|   | 'resolution_on_contract_with_users_shares_in' | ||||
|   | 'comment_on_contract_with_users_answer' | ||||
|   | 'update_on_contract_with_users_answer' | ||||
|   | 'resolution_on_contract_with_users_answer' | ||||
|   | 'answer_on_contract_with_users_answer' | ||||
|   | 'comment_on_contract_with_users_comment' | ||||
|   | 'answer_on_contract_with_users_comment' | ||||
|   | 'update_on_contract_with_users_comment' | ||||
|   | 'resolution_on_contract_with_users_comment' | ||||
|   | 'reply_to_users_answer' | ||||
|   | 'reply_to_users_comment' | ||||
|   | 'your_contract_closed' | ||||
|   | 'subsidized_your_market' | ||||
| 
 | ||||
| // Adding a new key:value here is optional, you can just use a key of notification_subscription_types
 | ||||
| // You might want to add a key:value here if there will be multiple notification reasons that map to the same
 | ||||
| // subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
 | ||||
| // 'all_comments_on_watched_markets' subscription type
 | ||||
| // TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
 | ||||
| export const notificationReasonToSubscriptionType: Partial< | ||||
|   Record<notification_reason_types, keyof notification_subscription_types> | ||||
| > = { | ||||
|   you_referred_user: 'referral_bonuses', | ||||
|   user_joined_to_bet_on_your_market: 'referral_bonuses', | ||||
|   tip_received: 'tips_on_your_comments', | ||||
|   bet_fill: 'limit_order_fills', | ||||
|   user_joined_from_your_group_invite: 'referral_bonuses', | ||||
|   challenge_accepted: 'limit_order_fills', | ||||
|   betting_streak_incremented: 'betting_streaks', | ||||
|   liked_and_tipped_your_contract: 'tips_on_your_markets', | ||||
|   comment_on_your_contract: 'all_comments_on_my_markets', | ||||
|   answer_on_your_contract: 'all_answers_on_my_markets', | ||||
|   comment_on_contract_you_follow: 'all_comments_on_watched_markets', | ||||
|   answer_on_contract_you_follow: 'all_answers_on_watched_markets', | ||||
|   update_on_contract_you_follow: 'market_updates_on_watched_markets', | ||||
|   resolution_on_contract_you_follow: 'resolutions_on_watched_markets', | ||||
|   comment_on_contract_with_users_shares_in: | ||||
|     'all_comments_on_contracts_with_shares_in_on_watched_markets', | ||||
|   answer_on_contract_with_users_shares_in: | ||||
|     'all_answers_on_contracts_with_shares_in_on_watched_markets', | ||||
|   update_on_contract_with_users_shares_in: | ||||
|     'market_updates_on_watched_markets_with_shares_in', | ||||
|   resolution_on_contract_with_users_shares_in: | ||||
|     'resolutions_on_watched_markets_with_shares_in', | ||||
|   comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', | ||||
|   update_on_contract_with_users_answer: 'market_updates_on_watched_markets', | ||||
|   resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', | ||||
|   answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', | ||||
|   comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', | ||||
|   answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', | ||||
|   update_on_contract_with_users_comment: 'market_updates_on_watched_markets', | ||||
|   resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', | ||||
|   reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', | ||||
|   reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', | ||||
| } | ||||
| 
 | ||||
| export const getDestinationsForUser = async ( | ||||
|   privateUser: PrivateUser, | ||||
|   reason: notification_reason_types | keyof notification_subscription_types | ||||
| ) => { | ||||
|   const notificationSettings = privateUser.notificationSubscriptionTypes | ||||
|   let destinations | ||||
|   let subscriptionType: keyof notification_subscription_types | undefined | ||||
|   if (Object.keys(notificationSettings).includes(reason)) { | ||||
|     subscriptionType = reason as keyof notification_subscription_types | ||||
|     destinations = notificationSettings[subscriptionType] | ||||
|   } else { | ||||
|     const key = reason as notification_reason_types | ||||
|     subscriptionType = notificationReasonToSubscriptionType[key] | ||||
|     destinations = subscriptionType | ||||
|       ? notificationSettings[subscriptionType] | ||||
|       : [] | ||||
|   } | ||||
|   return { | ||||
|     sendToEmail: destinations.includes('email'), | ||||
|     sendToBrowser: destinations.includes('browser'), | ||||
|     urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, | ||||
|   } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										192
									
								
								common/user.ts
									
									
									
									
									
								
							
							
						
						
									
										192
									
								
								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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) => | ||||
|  |  | |||
|  | @ -2,10 +2,30 @@ | |||
|   "functions": { | ||||
|     "predeploy": "cd functions && yarn build", | ||||
|     "runtime": "nodejs16", | ||||
|     "source": "functions/dist" | ||||
|     "source": "functions/dist", | ||||
|     "ignore": [ | ||||
|       "node_modules", | ||||
|       ".git", | ||||
|       "firebase-debug.log", | ||||
|       "firebase-debug.*.log" | ||||
|     ] | ||||
|   }, | ||||
|   "firestore": { | ||||
|     "rules": "firestore.rules", | ||||
|     "indexes": "firestore.indexes.json" | ||||
|   }, | ||||
|   "emulators": { | ||||
|     "functions": { | ||||
|       "port": 5001 | ||||
|     }, | ||||
|     "firestore": { | ||||
|       "port": 8080 | ||||
|     }, | ||||
|     "pubsub": { | ||||
|       "port": 8085 | ||||
|     }, | ||||
|     "ui": { | ||||
|       "enabled": true | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -77,7 +77,7 @@ service cloud.firestore { | |||
|       allow read: if userId == request.auth.uid || isAdmin(); | ||||
|       allow update: if (userId == request.auth.uid || isAdmin()) | ||||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                        .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); | ||||
|                        .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); | ||||
|     } | ||||
| 
 | ||||
|     match /private-users/{userId}/views/{viewId} { | ||||
|  | @ -161,7 +161,7 @@ service cloud.firestore { | |||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                                                                     .hasOnly(['isSeen', 'viewTime']); | ||||
|     } | ||||
| 
 | ||||
|      | ||||
|     match /{somePath=**}/groupMembers/{memberId} { | ||||
|       allow read; | ||||
|     } | ||||
|  | @ -170,7 +170,7 @@ service cloud.firestore { | |||
|       allow read; | ||||
|     } | ||||
| 
 | ||||
| 	   match /groups/{groupId} { | ||||
| 	    match /groups/{groupId} { | ||||
|           allow read; | ||||
|           allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) | ||||
|                            && request.resource.data.diff(resource.data) | ||||
|  | @ -184,7 +184,7 @@ service cloud.firestore { | |||
| 
 | ||||
|           match /groupMembers/{memberId}{ | ||||
|              allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); | ||||
|              allow delete: if request.auth.uid == resource.data.userId; | ||||
|               allow delete: if request.auth.uid == resource.data.userId; | ||||
|             } | ||||
| 
 | ||||
|           function isGroupMember() { | ||||
|  |  | |||
							
								
								
									
										1
									
								
								functions/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								functions/.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -17,4 +17,5 @@ package-lock.json | |||
| ui-debug.log | ||||
| firebase-debug.log | ||||
| firestore-debug.log | ||||
| pubsub-debug.log | ||||
| firestore_export/ | ||||
|  |  | |||
|  | @ -5,8 +5,7 @@ import { Contract } from '../../common/contract' | |||
| import { User } from '../../common/user' | ||||
| import { getNewMultiBetInfo } from '../../common/new-bet' | ||||
| import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' | ||||
| import { getContract, getValues } from './utils' | ||||
| import { sendNewAnswerEmail } from './emails' | ||||
| import { getValues } from './utils' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|  | @ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { | |||
|     return answer | ||||
|   }) | ||||
| 
 | ||||
|   const contract = await getContract(contractId) | ||||
| 
 | ||||
|   if (answer && contract) await sendNewAnswerEmail(answer, contract) | ||||
| 
 | ||||
|   return answer | ||||
| }) | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ import { | |||
|   MAX_GROUP_NAME_LENGTH, | ||||
|   MAX_ID_LENGTH, | ||||
| } from '../../common/group' | ||||
| import { APIError, newEndpoint, validate } from '../../functions/src/api' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|  |  | |||
|  | @ -1,13 +1,12 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { | ||||
|   getDestinationsForUser, | ||||
|   Notification, | ||||
|   notification_reason_types, | ||||
|   notification_source_update_types, | ||||
|   notification_source_types, | ||||
| } from '../../common/notification' | ||||
| import { User } from '../../common/user' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { getValues, log } from './utils' | ||||
| import { getPrivateUser, getValues } from './utils' | ||||
| import { Comment } from '../../common/comment' | ||||
| import { uniq } from 'lodash' | ||||
| import { Bet, LimitBet } from '../../common/bet' | ||||
|  | @ -15,20 +14,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<Answer>( | ||||
|       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<Comment>( | ||||
|       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') | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,11 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
| import { PrivateUser, User } from '../../common/user' | ||||
| import { | ||||
|   getDefaultNotificationSettings, | ||||
|   PrivateUser, | ||||
|   User, | ||||
| } from '../../common/user' | ||||
| import { getUser, getUserByUsername, getValues } from './utils' | ||||
| import { randomString } from '../../common/util/random' | ||||
| import { | ||||
|  | @ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { | |||
|     email, | ||||
|     initialIpAddress: req.ip, | ||||
|     initialDeviceToken: deviceToken, | ||||
|     notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), | ||||
|   } | ||||
| 
 | ||||
|   await firestore.collection('private-users').doc(auth.uid).create(privateUser) | ||||
|  |  | |||
|  | @ -284,9 +284,12 @@ | |||
|                                           style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                                           <div | ||||
|                                             style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> | ||||
|                                             <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a | ||||
|                                                 href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;" | ||||
|                                                 target="_blank">click here to unsubscribe</a>.</p> | ||||
|                                             <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, | ||||
|                                               <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                             </p> | ||||
|                                           </div> | ||||
|                                         </td> | ||||
|                                       </tr> | ||||
|  |  | |||
|  | @ -491,10 +491,10 @@ | |||
|                                     "> | ||||
|                                             <p style="margin: 10px 0"> | ||||
|                                               This e-mail has been sent to {{name}}, | ||||
|                                               <a href="{{unsubscribeLink}}" style=" | ||||
|                                               <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to unsubscribe</a>. | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                             </p> | ||||
|                                           </div> | ||||
|                                         </td> | ||||
|  |  | |||
|  | @ -440,11 +440,10 @@ | |||
|                                                                                     <p style="margin: 10px 0"> | ||||
|                                                                                         This e-mail has been sent to | ||||
|                                                                                         {{name}}, | ||||
|                                                                                         <a href="{{unsubscribeLink}}" | ||||
|                                                                                             style=" | ||||
|                                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to unsubscribe</a> from future recommended markets. | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                                                                     </p> | ||||
|                                                                                 </div> | ||||
|                                                                             </td> | ||||
|  |  | |||
|  | @ -526,19 +526,10 @@ | |||
|                       " | ||||
|                       >our Discord</a | ||||
|                     >! Or, | ||||
|                     <a | ||||
|                       href="{{unsubscribeUrl}}" | ||||
|                       style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       " | ||||
|                       >unsubscribe</a | ||||
|                     >. | ||||
|                     <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </table> | ||||
|  |  | |||
|  | @ -367,14 +367,9 @@ | |||
|                         margin: 0; | ||||
|                       ">our Discord</a>! Or, | ||||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       ">unsubscribe</a>. | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -485,14 +485,9 @@ | |||
|                         margin: 0; | ||||
|                       ">our Discord</a>! Or, | ||||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       ">unsubscribe</a>. | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -367,14 +367,9 @@ | |||
|                         margin: 0; | ||||
|                       ">our Discord</a>! Or, | ||||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       ">unsubscribe</a>. | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
							
								
								
									
										491
									
								
								functions/src/email-templates/market-resolved-no-bets.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										491
									
								
								functions/src/email-templates/market-resolved-no-bets.html
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,491 @@ | |||
| <!DOCTYPE html> | ||||
| <html style=" | ||||
|     font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|     box-sizing: border-box; | ||||
|     font-size: 14px; | ||||
|     margin: 0; | ||||
|   "> | ||||
| 
 | ||||
| <head> | ||||
|   <meta name="viewport" content="width=device-width" /> | ||||
|   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|   <title>Market resolved</title> | ||||
| 
 | ||||
|   <style type="text/css"> | ||||
|     img { | ||||
|       max-width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     body { | ||||
|       -webkit-font-smoothing: antialiased; | ||||
|       -webkit-text-size-adjust: none; | ||||
|       width: 100% !important; | ||||
|       height: 100%; | ||||
|       line-height: 1.6em; | ||||
|     } | ||||
| 
 | ||||
|     body { | ||||
|       background-color: #f6f6f6; | ||||
|     } | ||||
| 
 | ||||
|     @media only screen and (max-width: 640px) { | ||||
|       body { | ||||
|         padding: 0 !important; | ||||
|       } | ||||
| 
 | ||||
|       h1 { | ||||
|         font-weight: 800 !important; | ||||
|         margin: 20px 0 5px !important; | ||||
|       } | ||||
| 
 | ||||
|       h2 { | ||||
|         font-weight: 800 !important; | ||||
|         margin: 20px 0 5px !important; | ||||
|       } | ||||
| 
 | ||||
|       h3 { | ||||
|         font-weight: 800 !important; | ||||
|         margin: 20px 0 5px !important; | ||||
|       } | ||||
| 
 | ||||
|       h4 { | ||||
|         font-weight: 800 !important; | ||||
|         margin: 20px 0 5px !important; | ||||
|       } | ||||
| 
 | ||||
|       h1 { | ||||
|         font-size: 22px !important; | ||||
|       } | ||||
| 
 | ||||
|       h2 { | ||||
|         font-size: 18px !important; | ||||
|       } | ||||
| 
 | ||||
|       h3 { | ||||
|         font-size: 16px !important; | ||||
|       } | ||||
| 
 | ||||
|       .container { | ||||
|         padding: 0 !important; | ||||
|         width: 100% !important; | ||||
|       } | ||||
| 
 | ||||
|       .content { | ||||
|         padding: 0 !important; | ||||
|       } | ||||
| 
 | ||||
|       .content-wrap { | ||||
|         padding: 10px !important; | ||||
|       } | ||||
| 
 | ||||
|       .invoice { | ||||
|         width: 100% !important; | ||||
|       } | ||||
|     } | ||||
|   </style> | ||||
| </head> | ||||
| 
 | ||||
| <body itemscope itemtype="http://schema.org/EmailMessage" style=" | ||||
|       font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|       box-sizing: border-box; | ||||
|       font-size: 14px; | ||||
|       -webkit-font-smoothing: antialiased; | ||||
|       -webkit-text-size-adjust: none; | ||||
|       width: 100% !important; | ||||
|       height: 100%; | ||||
|       line-height: 1.6em; | ||||
|       background-color: #f6f6f6; | ||||
|       margin: 0; | ||||
|     " bgcolor="#f6f6f6"> | ||||
|   <table class="body-wrap" style=" | ||||
|         font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|         box-sizing: border-box; | ||||
|         font-size: 14px; | ||||
|         width: 100%; | ||||
|         background-color: #f6f6f6; | ||||
|         margin: 0; | ||||
|       " bgcolor="#f6f6f6"> | ||||
|     <tr style=" | ||||
|           font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|           box-sizing: border-box; | ||||
|           font-size: 14px; | ||||
|           margin: 0; | ||||
|         "> | ||||
|       <td style=" | ||||
|             font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|             box-sizing: border-box; | ||||
|             font-size: 14px; | ||||
|             vertical-align: top; | ||||
|             margin: 0; | ||||
|           " valign="top"></td> | ||||
|       <td class="container" width="600" style=" | ||||
|             font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|             box-sizing: border-box; | ||||
|             font-size: 14px; | ||||
|             vertical-align: top; | ||||
|             display: block !important; | ||||
|             max-width: 600px !important; | ||||
|             clear: both !important; | ||||
|             margin: 0 auto; | ||||
|           " valign="top"> | ||||
|         <div class="content" style=" | ||||
|               font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|               box-sizing: border-box; | ||||
|               font-size: 14px; | ||||
|               max-width: 600px; | ||||
|               display: block; | ||||
|               margin: 0 auto; | ||||
|               padding: 20px; | ||||
|             "> | ||||
|           <table class="main" width="100%" cellpadding="0" cellspacing="0" style=" | ||||
|                 font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                 box-sizing: border-box; | ||||
|                 font-size: 14px; | ||||
|                 border-radius: 3px; | ||||
|                 background-color: #fff; | ||||
|                 margin: 0; | ||||
|                 border: 1px solid #e9e9e9; | ||||
|               " bgcolor="#fff"> | ||||
|             <tr style=" | ||||
|                   font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                   box-sizing: border-box; | ||||
|                   font-size: 14px; | ||||
|                   margin: 0; | ||||
|                 "> | ||||
|               <td class="content-wrap aligncenter" style=" | ||||
|                     font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                     box-sizing: border-box; | ||||
|                     font-size: 14px; | ||||
|                     vertical-align: top; | ||||
|                     text-align: center; | ||||
|                     margin: 0; | ||||
|                     padding: 20px; | ||||
|                   " align="center" valign="top"> | ||||
|                 <table width="100%" cellpadding="0" cellspacing="0" style=" | ||||
|                       font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                         sans-serif; | ||||
|                       box-sizing: border-box; | ||||
|                       font-size: 14px; | ||||
|                       margin: 0; | ||||
|                       width: 90%; | ||||
|                     "> | ||||
|                   <tr style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 14px; | ||||
|                         margin: 0; | ||||
|                       "> | ||||
|                     <td class="content-block" style=" | ||||
|                           font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                             sans-serif; | ||||
|                           box-sizing: border-box; | ||||
|                           font-size: 16px; | ||||
|                           vertical-align: top; | ||||
|                           margin: 0; | ||||
|                           padding: 0 0 40px 0; | ||||
|                           text-align: left; | ||||
|                         " valign="top"> | ||||
|                       <a href="https://manifold.markets" target="_blank"> | ||||
|                         <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto" | ||||
|                           alt="Manifold Markets" /> | ||||
|                       </a> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 14px; | ||||
|                         margin: 0; | ||||
|                         padding: 0; | ||||
|                       "> | ||||
|                     <td class="content-block" style=" | ||||
|                           font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                             sans-serif; | ||||
|                           box-sizing: border-box; | ||||
|                           font-size: 16px; | ||||
|                           vertical-align: top; | ||||
|                           margin: 0; | ||||
|                           padding: 0 0 6px 0; | ||||
|                           text-align: left; | ||||
|                         " valign="top"> | ||||
|                       {{creatorName}} asked | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 14px; | ||||
|                         margin: 0; | ||||
|                       "> | ||||
|                     <td class="content-block" style=" | ||||
|                           font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                             sans-serif; | ||||
|                           box-sizing: border-box; | ||||
|                           font-size: 14px; | ||||
|                           vertical-align: top; | ||||
|                           margin: 0; | ||||
|                           padding: 0 0 20px; | ||||
|                         " valign="top"> | ||||
|                       <a href="{{url}}" style=" | ||||
|                             font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                               'Lucida Grande', sans-serif; | ||||
|                             box-sizing: border-box; | ||||
|                             font-size: 24px; | ||||
|                             color: #000; | ||||
|                             line-height: 1.2em; | ||||
|                             font-weight: 500; | ||||
|                             text-align: left; | ||||
|                             margin: 0 0 0 0; | ||||
|                             color: #4337c9; | ||||
|                             display: block; | ||||
|                             text-decoration: none; | ||||
|                           "> | ||||
|                         {{question}}</a> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 14px; | ||||
|                         margin: 0; | ||||
|                       "> | ||||
|                     <td class="content-block" style=" | ||||
|                           font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                             sans-serif; | ||||
|                           box-sizing: border-box; | ||||
|                           font-size: 14px; | ||||
|                           vertical-align: top; | ||||
|                           margin: 0; | ||||
|                           padding: 0 0 0px; | ||||
|                         " valign="top"> | ||||
|                       <h2 class="aligncenter" style=" | ||||
|                             font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                               'Lucida Grande', sans-serif; | ||||
|                             box-sizing: border-box; | ||||
|                             font-size: 24px; | ||||
|                             color: #000; | ||||
|                             line-height: 1.2em; | ||||
|                             font-weight: 500; | ||||
|                             text-align: center; | ||||
|                             margin: 10px 0 0; | ||||
|                           " align="center"> | ||||
|                         Resolved {{outcome}} | ||||
|                       </h2> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 16px; | ||||
|                         margin: 0; | ||||
|                       "> | ||||
|                     <td class="content-block aligncenter" style=" | ||||
|                           font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                             sans-serif; | ||||
|                           box-sizing: border-box; | ||||
|                           font-size: 16px; | ||||
|                           vertical-align: top; | ||||
|                           text-align: center; | ||||
|                           margin: 0; | ||||
|                           padding: 0; | ||||
|                         " align="center" valign="top"> | ||||
|                       <table class="invoice" style=" | ||||
|                             font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                               sans-serif; | ||||
|                             box-sizing: border-box; | ||||
|                             font-size: 16px; | ||||
|                             text-align: left; | ||||
|                             width: 80%; | ||||
|                             margin: 40px auto; | ||||
|                           "> | ||||
|                         <tr style=" | ||||
|                               font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                                 sans-serif; | ||||
|                               box-sizing: border-box; | ||||
|                               font-size: 16px; | ||||
|                               margin: 0; | ||||
|                             "> | ||||
|                           <td style=" | ||||
|                                 font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                                   sans-serif; | ||||
|                                 box-sizing: border-box; | ||||
|                                 font-size: 16px; | ||||
|                                 vertical-align: top; | ||||
|                                 margin: 0; | ||||
|                                 padding: 5px 0; | ||||
|                               " valign="top"> | ||||
|                             Dear {{name}}, | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             A market you were following has been resolved! | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             Thanks, | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             Manifold Team | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                             <br style=" | ||||
|                                   font-family: 'Helvetica Neue', Helvetica, | ||||
|                                     Arial, sans-serif; | ||||
|                                   box-sizing: border-box; | ||||
|                                   font-size: 16px; | ||||
|                                   margin: 0; | ||||
|                                 " /> | ||||
|                           </td> | ||||
|                         </tr> | ||||
|                         <tr style=" | ||||
|                               font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                                 sans-serif; | ||||
|                               box-sizing: border-box; | ||||
|                               font-size: 16px; | ||||
|                               margin: 0; | ||||
|                             "> | ||||
|                           <td style="padding: 10px 0 0 0; margin: 0"> | ||||
|                             <div align="center"> | ||||
|                               <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]--> | ||||
|                               <a href="{{url}}" target="_blank" style=" | ||||
|                                     box-sizing: border-box; | ||||
|                                     display: inline-block; | ||||
|                                     font-family: arial, helvetica, sans-serif; | ||||
|                                     text-decoration: none; | ||||
|                                     -webkit-text-size-adjust: none; | ||||
|                                     text-align: center; | ||||
|                                     color: #ffffff; | ||||
|                                     background-color: #11b981; | ||||
|                                     border-radius: 4px; | ||||
|                                     -webkit-border-radius: 4px; | ||||
|                                     -moz-border-radius: 4px; | ||||
|                                     width: auto; | ||||
|                                     max-width: 100%; | ||||
|                                     overflow-wrap: break-word; | ||||
|                                     word-break: break-word; | ||||
|                                     word-wrap: break-word; | ||||
|                                     mso-border-alt: none; | ||||
|                                   "> | ||||
|                                 <span style=" | ||||
|                                       display: block; | ||||
|                                       padding: 10px 20px; | ||||
|                                       line-height: 120%; | ||||
|                                     "><span style=" | ||||
|                                         font-size: 16px; | ||||
|                                         font-weight: bold; | ||||
|                                         line-height: 18.8px; | ||||
|                                       ">View market</span></span> | ||||
|                               </a> | ||||
|                               <!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> | ||||
|                             </div> | ||||
|                           </td> | ||||
|                         </tr> | ||||
|                       </table> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                 </table> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </table> | ||||
|           <div class="footer" style=" | ||||
|                 font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                 box-sizing: border-box; | ||||
|                 font-size: 14px; | ||||
|                 width: 100%; | ||||
|                 clear: both; | ||||
|                 color: #999; | ||||
|                 margin: 0; | ||||
|                 padding: 20px; | ||||
|               "> | ||||
|             <table width="100%" style=" | ||||
|                   font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                   box-sizing: border-box; | ||||
|                   font-size: 14px; | ||||
|                   margin: 0; | ||||
|                 "> | ||||
|               <tr style=" | ||||
|                     font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|                     box-sizing: border-box; | ||||
|                     font-size: 14px; | ||||
|                     margin: 0; | ||||
|                   "> | ||||
|                 <td class="aligncenter content-block" style=" | ||||
|                       font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                         sans-serif; | ||||
|                       box-sizing: border-box; | ||||
|                       font-size: 12px; | ||||
|                       vertical-align: top; | ||||
|                       color: #999; | ||||
|                       text-align: center; | ||||
|                       margin: 0; | ||||
|                       padding: 0 0 20px; | ||||
|                     " align="center" valign="top"> | ||||
|                   Questions? Come ask in | ||||
|                   <a href="https://discord.gg/eHQBNBqXuh" style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       ">our Discord</a>! Or, | ||||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|           </div> | ||||
|         </div> | ||||
|       </td> | ||||
|       <td style=" | ||||
|             font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
|             box-sizing: border-box; | ||||
|             font-size: 14px; | ||||
|             vertical-align: top; | ||||
|             margin: 0; | ||||
|           " valign="top"></td> | ||||
|     </tr> | ||||
|   </table> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | @ -500,14 +500,9 @@ | |||
|                         margin: 0; | ||||
|                       ">our Discord</a>! Or, | ||||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                         font-family: 'Helvetica Neue', Helvetica, Arial, | ||||
|                           sans-serif; | ||||
|                         box-sizing: border-box; | ||||
|                         font-size: 12px; | ||||
|                         color: #999; | ||||
|                         text-decoration: underline; | ||||
|                         margin: 0; | ||||
|                       ">unsubscribe</a>. | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
							
								
								
									
										354
									
								
								functions/src/email-templates/new-market-from-followed-user.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										354
									
								
								functions/src/email-templates/new-market-from-followed-user.html
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,354 @@ | |||
| <!DOCTYPE html> | ||||
| <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" | ||||
|     xmlns:o="urn:schemas-microsoft-com:office:office"> | ||||
| 
 | ||||
| <head> | ||||
|     <title>New market from {{creatorName}}</title> | ||||
|     <!--[if !mso]><!--> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <!--<![endif]--> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width,initial-scale=1" /> | ||||
|     <style type="text/css"> | ||||
|         #outlook a { | ||||
|             padding: 0; | ||||
|         } | ||||
| 
 | ||||
|         body { | ||||
|             margin: 0; | ||||
|             padding: 0; | ||||
|             -webkit-text-size-adjust: 100%; | ||||
|             -ms-text-size-adjust: 100%; | ||||
|         } | ||||
| 
 | ||||
|         table, | ||||
|         td { | ||||
|             border-collapse: collapse; | ||||
|             mso-table-lspace: 0pt; | ||||
|             mso-table-rspace: 0pt; | ||||
|         } | ||||
| 
 | ||||
|         img { | ||||
|             border: 0; | ||||
|             height: auto; | ||||
|             line-height: 100%; | ||||
|             outline: none; | ||||
|             text-decoration: none; | ||||
|             -ms-interpolation-mode: bicubic; | ||||
|         } | ||||
| 
 | ||||
|         p { | ||||
|             display: block; | ||||
|             margin: 13px 0; | ||||
|         } | ||||
|     </style> | ||||
|     <!--[if mso]> | ||||
|       <noscript> | ||||
|         <xml> | ||||
|           <o:OfficeDocumentSettings> | ||||
|             <o:AllowPNG /> | ||||
|             <o:PixelsPerInch>96</o:PixelsPerInch> | ||||
|           </o:OfficeDocumentSettings> | ||||
|         </xml> | ||||
|       </noscript> | ||||
|     <![endif]--> | ||||
|     <!--[if lte mso 11]> | ||||
|       <style type="text/css"> | ||||
|         .mj-outlook-group-fix { | ||||
|           width: 100% !important; | ||||
|         } | ||||
|       </style> | ||||
|     <![endif]--> | ||||
|     <!--[if !mso]><!--> | ||||
|     <link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" /> | ||||
|     <link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" /> | ||||
|     <link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" /> | ||||
|     <style type="text/css"> | ||||
|         @import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700); | ||||
|         @import url(https://fonts.googleapis.com/css?family=Readex+Pro); | ||||
|         @import url(https://fonts.googleapis.com/css?family=Readex+Pro); | ||||
|     </style> | ||||
|     <!--<![endif]--> | ||||
|     <style type="text/css"> | ||||
|         @media only screen and (min-width: 480px) { | ||||
|             .mj-column-per-100 { | ||||
|                 width: 100% !important; | ||||
|                 max-width: 100%; | ||||
|             } | ||||
|         } | ||||
|     </style> | ||||
|     <style media="screen and (min-width:480px)"> | ||||
|         .moz-text-html .mj-column-per-100 { | ||||
|             width: 100% !important; | ||||
|             max-width: 100%; | ||||
|         } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|         [owa] .mj-column-per-100 { | ||||
|             width: 100% !important; | ||||
|             max-width: 100%; | ||||
|         } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|         @media only screen and (max-width: 480px) { | ||||
|             table.mj-full-width-mobile { | ||||
|                 width: 100% !important; | ||||
|             } | ||||
| 
 | ||||
|             td.mj-full-width-mobile { | ||||
|                 width: auto !important; | ||||
|             } | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
| 
 | ||||
| <body style="word-spacing: normal; background-color: #f4f4f4"> | ||||
|     <div style="margin:0px auto;max-width:600px;"> | ||||
| 
 | ||||
|         <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> | ||||
|             <tbody> | ||||
|                 <tr> | ||||
|                     <td | ||||
|                         style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;"> | ||||
|                         <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
| 
 | ||||
|                         <div class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                             style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> | ||||
| 
 | ||||
|                             <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                                 style="vertical-align:top;" width="100%"> | ||||
|                                 <tbody> | ||||
| 
 | ||||
|                                     <tr> | ||||
|                                         <td align="center" | ||||
|                                             style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"> | ||||
| 
 | ||||
|                                             <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                                                 style="border-collapse:collapse;border-spacing:0px;"> | ||||
|                                                 <tbody> | ||||
|                                                     <tr> | ||||
|                                                         <td style="width:550px;"> | ||||
| 
 | ||||
|                                                             <a href="https://manifold.markets" target="_blank"> | ||||
| 
 | ||||
|                                                                 <img alt="banner logo" height="auto" | ||||
|                                                                     src="https://manifold.markets/logo-banner.png" | ||||
|                                                                     style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" | ||||
|                                                                     title="" width="550"> | ||||
| 
 | ||||
|                                                             </a> | ||||
| 
 | ||||
|                                                         </td> | ||||
|                                                     </tr> | ||||
|                                                 </tbody> | ||||
|                                             </table> | ||||
| 
 | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|                                 </tbody> | ||||
|                             </table> | ||||
| 
 | ||||
|                         </div> | ||||
| 
 | ||||
|                         <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             </tbody> | ||||
|         </table> | ||||
| 
 | ||||
|     </div> | ||||
|     <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|     <div style=" | ||||
|           background: #ffffff; | ||||
|           background-color: #ffffff; | ||||
|           margin: 0px auto; | ||||
|           max-width: 600px; | ||||
|         "> | ||||
|         <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|             style="background: #ffffff; background-color: #ffffff; width: 100%"> | ||||
|             <tbody> | ||||
| 
 | ||||
|                 <tr> | ||||
|                     <td style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 20px 0px 0px 0px; | ||||
|                   padding-bottom: 0px; | ||||
|                   padding-left: 0px; | ||||
|                   padding-right: 0px; | ||||
|                   padding-top: 20px; | ||||
|                   text-align: center; | ||||
|                 "> | ||||
|                         <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                         <div class="mj-column-per-100 mj-outlook-group-fix" style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   "> | ||||
|                             <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                                 style="vertical-align: top" width="100%"> | ||||
|                                 <tbody> | ||||
|                                     <tr> | ||||
|                                         <td align="left" | ||||
|                                             style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                                             <div | ||||
|                                                 style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> | ||||
|                                                 <p class="text-build-content" | ||||
|                                                     style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" | ||||
|                                                     data-testid="4XoHRGw1Y"><span | ||||
|                                                         style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> | ||||
|                                                     </span>Hi {{name}},</p> | ||||
|                                             </div> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
| 
 | ||||
|                                     <tr> | ||||
|                                         <td align="left" | ||||
|                                             style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"> | ||||
|                                             <div | ||||
|                                                 style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> | ||||
|                                                 <p class="text-build-content" | ||||
|                                                     style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" | ||||
|                                                     data-testid="4XoHRGw1Y"><span | ||||
|                                                         style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> | ||||
|                                                         {{creatorName}}, (who you're following) just created a new market, check it out!</span></p> | ||||
|                                             </div> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
| 
 | ||||
|                                     <tr> | ||||
|                                         <td align="center" | ||||
|                                             style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"> | ||||
|                                             <div | ||||
|                                                 style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;"> | ||||
|                                                 <a href="{{questionUrl}}"> | ||||
|                                                     <img alt="{{questionTitle}}" width="375" height="200" | ||||
|                                                         style="border: 1px solid #4337c9;" src="{{questionImgSrc}}"> | ||||
|                                                 </a> | ||||
|                                             </div> | ||||
| 
 | ||||
|                                             <table cellspacing="0" cellpadding="0"> | ||||
|                                                 <tr> | ||||
|                                                     <td style="border-radius: 4px;" bgcolor="#4337c9"> | ||||
|                                                         <a href="{{questionUrl}}" target="_blank" | ||||
|                                                             style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;"> | ||||
|                                                             View market | ||||
|                                                         </a> | ||||
|                                                     </td> | ||||
|                                                 </tr> | ||||
|                                             </table> | ||||
| 
 | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
| 
 | ||||
|                                 </tbody> | ||||
|                             </table> | ||||
|                         </div> | ||||
|                         <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
| 
 | ||||
|     <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|     <div style="margin: 0px auto; max-width: 600px"> | ||||
|         <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%"> | ||||
|             <tbody> | ||||
|                 <tr> | ||||
|                     <td style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 0 0 20px 0; | ||||
|                   text-align: center; | ||||
|                 "> | ||||
| 
 | ||||
|                         <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|                         <div style="margin: 0px auto; max-width: 600px"> | ||||
|                             <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                                 style="width: 100%"> | ||||
|                                 <tbody> | ||||
|                                     <tr> | ||||
|                                         <td style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 20px 0px 20px 0px; | ||||
|                   text-align: center; | ||||
|                 "> | ||||
|                                             <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                                             <div class="mj-column-per-100 mj-outlook-group-fix" style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   "> | ||||
|                                                 <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                                                     width="100%"> | ||||
|                                                     <tbody> | ||||
|                                                         <tr> | ||||
|                                                             <td style="vertical-align: top; padding: 0"> | ||||
|                                                                 <table border="0" cellpadding="0" cellspacing="0" | ||||
|                                                                     role="presentation" width="100%"> | ||||
|                                                                     <tbody> | ||||
|                                                                         <tr> | ||||
|                                                                             <td align="center" style=" | ||||
|                                     font-size: 0px; | ||||
|                                     padding: 10px 25px; | ||||
|                                     word-break: break-word; | ||||
|                                   "> | ||||
|                                                                                 <div style=" | ||||
|                                       font-family: Ubuntu, Helvetica, Arial, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 11px; | ||||
|                                       line-height: 22px; | ||||
|                                       text-align: center; | ||||
|                                       color: #000000; | ||||
|                                     "> | ||||
|                                                                                     <p style="margin: 10px 0"> | ||||
|                                                                                         This e-mail has been sent to | ||||
|                                                                                         {{name}}, | ||||
|                                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                                                                     </p> | ||||
|                                                                                 </div> | ||||
|                                                                             </td> | ||||
|                                                                         </tr> | ||||
|                                                                         <tr> | ||||
|                                                                             <td align="center" style=" | ||||
|                                     font-size: 0px; | ||||
|                                     padding: 10px 25px; | ||||
|                                     word-break: break-word; | ||||
|                                   "></td> | ||||
|                                                                         </tr> | ||||
|                                                                     </tbody> | ||||
|                                                                 </table> | ||||
|                                                             </td> | ||||
|                                                         </tr> | ||||
|                                                     </tbody> | ||||
|                                                 </table> | ||||
|                                             </div> | ||||
|                                             <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 </tbody> | ||||
|                             </table> | ||||
|                         </div> | ||||
|                         <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | @ -1,519 +1,316 @@ | |||
| <!DOCTYPE html> | ||||
| <html | ||||
|   xmlns="http://www.w3.org/1999/xhtml" | ||||
|   xmlns:v="urn:schemas-microsoft-com:vml" | ||||
|   xmlns:o="urn:schemas-microsoft-com:office:office" | ||||
| > | ||||
|   <head> | ||||
|     <title>7th Day Anniversary Gift!</title> | ||||
|     <!--[if !mso]><!--> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <!--<![endif]--> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width,initial-scale=1" /> | ||||
|     <style type="text/css"> | ||||
| <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" | ||||
|       xmlns:o="urn:schemas-microsoft-com:office:office"> | ||||
| 
 | ||||
| <head> | ||||
|   <title>Manifold Markets 7th Day Anniversary Gift!</title> | ||||
|   <!--[if !mso]><!--> | ||||
|   <meta http-equiv="X-UA-Compatible" content="IE=edge"> | ||||
|   <!--<![endif]--> | ||||
|   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> | ||||
|   <meta name="viewport" content="width=device-width,initial-scale=1"> | ||||
|   <style type="text/css"> | ||||
|       #outlook a { | ||||
|         padding: 0; | ||||
|           padding: 0; | ||||
|       } | ||||
| 
 | ||||
|       body { | ||||
|         margin: 0; | ||||
|         padding: 0; | ||||
|         -webkit-text-size-adjust: 100%; | ||||
|         -ms-text-size-adjust: 100%; | ||||
|           margin: 0; | ||||
|           padding: 0; | ||||
|           -webkit-text-size-adjust: 100%; | ||||
|           -ms-text-size-adjust: 100%; | ||||
|       } | ||||
| 
 | ||||
|       table, | ||||
|       td { | ||||
|         border-collapse: collapse; | ||||
|         mso-table-lspace: 0pt; | ||||
|         mso-table-rspace: 0pt; | ||||
|           border-collapse: collapse; | ||||
|           mso-table-lspace: 0pt; | ||||
|           mso-table-rspace: 0pt; | ||||
|       } | ||||
| 
 | ||||
|       img { | ||||
|         border: 0; | ||||
|         height: auto; | ||||
|         line-height: 100%; | ||||
|         outline: none; | ||||
|         text-decoration: none; | ||||
|         -ms-interpolation-mode: bicubic; | ||||
|           border: 0; | ||||
|           height: auto; | ||||
|           line-height: 100%; | ||||
|           outline: none; | ||||
|           text-decoration: none; | ||||
|           -ms-interpolation-mode: bicubic; | ||||
|       } | ||||
| 
 | ||||
|       p { | ||||
|         display: block; | ||||
|         margin: 13px 0; | ||||
|           display: block; | ||||
|           margin: 13px 0; | ||||
|       } | ||||
|     </style> | ||||
|     <!--[if mso]> | ||||
|       <noscript> | ||||
|         <xml> | ||||
|           <o:OfficeDocumentSettings> | ||||
|             <o:AllowPNG /> | ||||
|             <o:PixelsPerInch>96</o:PixelsPerInch> | ||||
|           </o:OfficeDocumentSettings> | ||||
|         </xml> </noscript | ||||
|       >z | ||||
|     <![endif]--> | ||||
|     <!--[if lte mso 11]> | ||||
|       <style type="text/css"> | ||||
|         .mj-outlook-group-fix { | ||||
|           width: 100% !important; | ||||
|         } | ||||
|       </style> | ||||
|     <![endif]--> | ||||
|     <style type="text/css"> | ||||
|       @media only screen and (min-width: 480px) { | ||||
|         .mj-column-per-100 { | ||||
|   </style> | ||||
|   <!--[if mso]> | ||||
|   <noscript> | ||||
|     <xml> | ||||
|       <o:OfficeDocumentSettings> | ||||
|         <o:AllowPNG/> | ||||
|         <o:PixelsPerInch>96</o:PixelsPerInch> | ||||
|       </o:OfficeDocumentSettings> | ||||
|     </xml> | ||||
|   </noscript> | ||||
|   <![endif]--> | ||||
|   <!--[if lte mso 11]> | ||||
|   <style type="text/css"> | ||||
|     .mj-outlook-group-fix { width:100% !important; } | ||||
|   </style> | ||||
|   <![endif]--> | ||||
|   <style type="text/css"> | ||||
|       @media only screen and (min-width:480px) { | ||||
|           .mj-column-per-100 { | ||||
|               width: 100% !important; | ||||
|               max-width: 100%; | ||||
|           } | ||||
|       } | ||||
|   </style> | ||||
|   <style media="screen and (min-width:480px)"> | ||||
|       .moz-text-html .mj-column-per-100 { | ||||
|           width: 100% !important; | ||||
|           max-width: 100%; | ||||
|         } | ||||
|       } | ||||
|     </style> | ||||
|     <style media="screen and (min-width:480px)"> | ||||
|       .moz-text-html .mj-column-per-100 { | ||||
|         width: 100% !important; | ||||
|         max-width: 100%; | ||||
|       } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|   </style> | ||||
|   <style type="text/css"> | ||||
|       [owa] .mj-column-per-100 { | ||||
|         width: 100% !important; | ||||
|         max-width: 100%; | ||||
|       } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|       @media only screen and (max-width: 480px) { | ||||
|         table.mj-full-width-mobile { | ||||
|           width: 100% !important; | ||||
|         } | ||||
| 
 | ||||
|         td.mj-full-width-mobile { | ||||
|           width: auto !important; | ||||
|         } | ||||
|           max-width: 100%; | ||||
|       } | ||||
|     </style> | ||||
|   </head> | ||||
|   </style> | ||||
|   <style type="text/css"> | ||||
|       @media only screen and (max-width:480px) { | ||||
|           table.mj-full-width-mobile { | ||||
|               width: 100% !important; | ||||
|           } | ||||
| 
 | ||||
|   <body style="word-spacing: normal; background-color: #f4f4f4"> | ||||
|     <div style="background-color: #f4f4f4"> | ||||
|       <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div | ||||
|         style=" | ||||
|           background: #ffffff; | ||||
|           background-color: #ffffff; | ||||
|           margin: 0px auto; | ||||
|           max-width: 600px; | ||||
|         " | ||||
|       > | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="background: #ffffff; background-color: #ffffff; width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 0px 0px 0px 0px; | ||||
|                   padding-bottom: 0px; | ||||
|                   padding-left: 0px; | ||||
|                   padding-right: 0px; | ||||
|                   padding-top: 0px; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                 <div | ||||
|                   class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                   style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   " | ||||
|                 > | ||||
|                   <table | ||||
|                     border="0" | ||||
|                     cellpadding="0" | ||||
|                     cellspacing="0" | ||||
|                     role="presentation" | ||||
|                     style="vertical-align: top" | ||||
|                     width="100%" | ||||
|                   > | ||||
|           td.mj-full-width-mobile { | ||||
|               width: auto !important; | ||||
|           } | ||||
|       } | ||||
|   </style> | ||||
| </head> | ||||
| 
 | ||||
| <body style="word-spacing:normal;background-color:#F4F4F4;"> | ||||
| <div style="background-color:#F4F4F4;"> | ||||
|   <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|   <div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;"> | ||||
|     <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|            style="background:#ffffff;background-color:#ffffff;width:100%;"> | ||||
|       <tbody> | ||||
|       <tr> | ||||
|         <td | ||||
|           style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;"> | ||||
|           <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|           <div class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> | ||||
|             <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" | ||||
|                    width="100%"> | ||||
|               <tbody> | ||||
|               <tr> | ||||
|                 <td align="center" | ||||
|                     style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"> | ||||
|                   <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                          style="border-collapse:collapse;border-spacing:0px;"> | ||||
|                     <tbody> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="center" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 0px 25px 0px 25px; | ||||
|                             padding-top: 0px; | ||||
|                             padding-right: 25px; | ||||
|                             padding-bottom: 0px; | ||||
|                             padding-left: 25px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <table | ||||
|                             border="0" | ||||
|                             cellpadding="0" | ||||
|                             cellspacing="0" | ||||
|                             role="presentation" | ||||
|                             style=" | ||||
|                               border-collapse: collapse; | ||||
|                               border-spacing: 0px; | ||||
|                             " | ||||
|                           > | ||||
|                             <tbody> | ||||
|                               <tr> | ||||
|                                 <td style="width: 550px"> | ||||
|                                   <a | ||||
|                                     href="https://manifold.markets/home" | ||||
|                                     target="_blank" | ||||
|                                     ><img | ||||
|                                       alt="" | ||||
|                                       height="auto" | ||||
|                                       src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.gif" | ||||
|                                       style=" | ||||
|                                         border: none; | ||||
|                                         display: block; | ||||
|                                         outline: none; | ||||
|                                         text-decoration: none; | ||||
|                                         height: auto; | ||||
|                                         width: 100%; | ||||
|                                         font-size: 13px; | ||||
|                                       " | ||||
|                                       width="550" | ||||
|                                   /></a> | ||||
|                                 </td> | ||||
|                               </tr> | ||||
|                             </tbody> | ||||
|                           </table> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="left" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 10px 25px; | ||||
|                             padding-top: 0px; | ||||
|                             padding-bottom: 0px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <div | ||||
|                             style=" | ||||
|                               font-family: Arial, sans-serif; | ||||
|                               font-size: 18px; | ||||
|                               letter-spacing: normal; | ||||
|                               line-height: 1; | ||||
|                               text-align: left; | ||||
|                               color: #000000; | ||||
|                             " | ||||
|                           > | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style=" | ||||
|                                 text-align: center; | ||||
|                                 margin: 10px 0; | ||||
|                                 margin-top: 10px; | ||||
|                                 margin-bottom: 10px; | ||||
|                               " | ||||
|                               data-testid="4XoHRGw1Y" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Arial, Helvetica, sans-serif; | ||||
|                                   font-size: 18px; | ||||
|                                 " | ||||
|                                 >Hopefully you haven't gambled all your M$ | ||||
|                                 away already... but if you have I bring good | ||||
|                                 news! Click the link below to recieve a one time | ||||
|                                 gift of M$ 500 to your account!</span | ||||
|                               > | ||||
|                             </p> | ||||
|                           </div> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="center" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 10px 25px 25px 25px; | ||||
|                             padding-top: 10px; | ||||
|                             padding-right: 25px; | ||||
|                             padding-bottom: 25px; | ||||
|                             padding-left: 25px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <table | ||||
|                             border="0" | ||||
|                             cellpadding="0" | ||||
|                             cellspacing="0" | ||||
|                             role="presentation" | ||||
|                             style=" | ||||
|                               border-collapse: collapse; | ||||
|                               border-spacing: 0px; | ||||
|                             " | ||||
|                           > | ||||
|                             <tbody> | ||||
|                               <tr> | ||||
|                                 <td style="width: 550px"> | ||||
|                                   <a href="{{manalink}}" target="_blank"> | ||||
|                                     <img | ||||
|                                       alt="Get M$500" | ||||
|                                       height="auto" | ||||
|                                       src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png" | ||||
|                                       style=" | ||||
|                                         border: none; | ||||
|                                         display: block; | ||||
|                                         outline: none; | ||||
|                                         text-decoration: none; | ||||
|                                         height: auto; | ||||
|                                         width: 100%; | ||||
|                                         font-size: 13px; | ||||
|                                       " | ||||
|                                       width="550" | ||||
|                                   /></a> | ||||
|                                   << /td> | ||||
|                                 </td> | ||||
|                               </tr> | ||||
|                             </tbody> | ||||
|                           </table> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="left" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 10px 25px; | ||||
|                             padding-top: 0px; | ||||
|                             padding-bottom: 0px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <div | ||||
|                             style=" | ||||
|                               font-family: Arial, sans-serif; | ||||
|                               font-size: 18px; | ||||
|                               letter-spacing: normal; | ||||
|                               line-height: 1; | ||||
|                               text-align: left; | ||||
|                               color: #000000; | ||||
|                             " | ||||
|                           > | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style=" | ||||
|                                 line-height: 23px; | ||||
|                                 text-align: center; | ||||
|                                 margin: 10px 0; | ||||
|                                 margin-top: 10px; | ||||
|                               " | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Arial, Helvetica, sans-serif; | ||||
|                                   font-size: 18px; | ||||
|                                 " | ||||
|                                 >If you are still engaging with our markets then | ||||
|                                 at this point you might as well join our </span | ||||
|                               ><a | ||||
|                                 class="link-build-content" | ||||
|                                 style="color: inherit; text-decoration: none" | ||||
|                                 target="_blank" | ||||
|                                 href="https://discord.gg/VARzUpyCSa" | ||||
|                                 ><span | ||||
|                                   style=" | ||||
|                                     color: #0c21bf; | ||||
|                                     font-family: Arial; | ||||
|                                     font-size: 18px; | ||||
|                                   " | ||||
|                                   ><u>Discord server</u></span | ||||
|                                 ><span | ||||
|                                   style=" | ||||
|                                     color: #000000; | ||||
|                                     font-family: Arial; | ||||
|                                     font-size: 18px; | ||||
|                                   " | ||||
|                                   ><u>.</u> | ||||
|                                 </span></a | ||||
|                               ><span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Arial; | ||||
|                                   font-size: 18px; | ||||
|                                 " | ||||
|                                 >You can always leave if you dont like it but | ||||
|                                 I'd be willing to make a market betting | ||||
|                                 you'll stay.</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                               style="margin: 10px 0" | ||||
|                             ></p> | ||||
|                             <br /> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                               style="margin: 10px 0" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Arial; | ||||
|                                   font-size: 18px; | ||||
|                                 " | ||||
|                                 >Cheers,</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                               style="margin: 10px 0" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Arial; | ||||
|                                   font-size: 18px; | ||||
|                                 " | ||||
|                                 >David from Manifold</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                               style="margin: 10px 0; margin-bottom: 10px" | ||||
|                             ></p> | ||||
|                           </div> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                     <tr> | ||||
|                       <td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img | ||||
|                         alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif" | ||||
|                         style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" | ||||
|                         width="550"></a></td> | ||||
|                     </tr> | ||||
|                     </tbody> | ||||
|                   </table> | ||||
|                 </div> | ||||
|                 <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div style="margin: 0px auto; max-width: 600px"> | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 20px 0px 20px 0px; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                 <div | ||||
|                   class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                   style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   " | ||||
|                 > | ||||
|                   <table | ||||
|                     border="0" | ||||
|                     cellpadding="0" | ||||
|                     cellspacing="0" | ||||
|                     role="presentation" | ||||
|                     width="100%" | ||||
|                   > | ||||
|                     <tbody> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td align="left" | ||||
|                     style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                   <div | ||||
|                     style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> | ||||
|                     <p class="text-build-content" | ||||
|                        style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" | ||||
|                        data-testid="4XoHRGw1Y"><span | ||||
|                       style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> | ||||
|                               Hi {{name}},</span></p> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td align="left" | ||||
|                     style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                   <div | ||||
|                     style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> | ||||
|                     <p class="text-build-content" | ||||
|                        style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" | ||||
|                        data-testid="4XoHRGw1Y"><span | ||||
|                       style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for | ||||
|                               using Manifold Markets. Running low | ||||
|                               on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td> | ||||
|                   <p></p> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td align="center"> | ||||
|                   <table cellspacing="0" cellpadding="0"> | ||||
|                     <tr> | ||||
|                       <td> | ||||
|                         <table cellspacing="0" cellpadding="0"> | ||||
|                           <tr> | ||||
|                             <td style="border-radius: 2px;" bgcolor="#4337c9"> | ||||
|                               <a href="{{manalink}}" target="_blank" | ||||
|                                  style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;"> | ||||
|                                 Claim M$500 | ||||
|                               </a> | ||||
|                             </td> | ||||
|                           </tr> | ||||
|                         </table> | ||||
|                       </td> | ||||
|                     </tr> | ||||
|                   </table> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td align="left" | ||||
|                     style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"> | ||||
|                   <div | ||||
|                     style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> | ||||
|                     <p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;" | ||||
|                        data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did | ||||
|                               you know, besides making correct predictions, there are | ||||
|                               plenty of other ways to earn mana?</span></p> | ||||
|                     <ul> | ||||
|                       <li style="line-height:23px;"><span | ||||
|                         style="font-family:Arial, sans-serif;font-size:18px;">Predicting | ||||
|                         consecutive days to earn streak rewards</span></li> | ||||
|                       <li style="line-height:23px;"><span | ||||
|                         style="font-family:Arial, sans-serif;font-size:18px;">Receiving | ||||
|                                 tips on comments and markets</span></li> | ||||
|                       <li style="line-height:23px;"><span | ||||
|                         style="font-family:Arial, sans-serif;font-size:18px;">Unique | ||||
|                                 predictor bonus for each user who predicts on your | ||||
|                                 markets</span></li> | ||||
|                       <li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a | ||||
|                         class="link-build-content" style="color:inherit;; text-decoration: none;" | ||||
|                         target="_blank" href="https://manifold.markets/referrals"><span | ||||
|                         style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring | ||||
|                                       friends</u></span></a></span></li> | ||||
|                       <li style="line-height:23px;"><a class="link-build-content" | ||||
|                                                        style="color:inherit;; text-decoration: none;" target="_blank" | ||||
|                                                        href="https://manifold.markets/group/bugs?s=most-traded"><span | ||||
|                         style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting | ||||
|                                     bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;"> | ||||
|                                 and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;" | ||||
|                                               target="_blank" | ||||
|                                               href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span | ||||
|                         style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving | ||||
|                                     feedback</u></span></a></li> | ||||
|                     </ul> | ||||
|                     <p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p> | ||||
|                     <p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span | ||||
|                       style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span> | ||||
|                     </p> | ||||
|                     <p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span | ||||
|                       style="color:#000000;font-family:Arial;font-size:18px;">David | ||||
|                               from Manifold</span></p> | ||||
|                     <p class="text-build-content" data-testid="3Q8BP69fq" | ||||
|                        style="margin: 10px 0; margin-bottom: 10px;"> </p> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               </tbody> | ||||
|             </table> | ||||
|           </div> | ||||
|           <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|         </td> | ||||
|       </tr> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
|   <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|   <div style="margin:0px auto;max-width:600px;"> | ||||
|     <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> | ||||
|       <tbody> | ||||
|       <tr> | ||||
|         <td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;"> | ||||
|           <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|           <div class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> | ||||
|             <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" | ||||
|                    width="100%"> | ||||
|               <tbody> | ||||
|               <tr> | ||||
|                 <td align="center" style="font-size:0px;padding:0px;word-break:break-word;"> | ||||
|                   <table border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                          style="border-collapse:collapse;border-spacing:0px;"> | ||||
|           </div> | ||||
|           <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|           <div style="margin:0px auto;max-width:600px;"> | ||||
|             <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" | ||||
|                    style="width:100%;"> | ||||
|               <tbody> | ||||
|               <tr> | ||||
|                 <td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;"> | ||||
|                   <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                   <div class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                        style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> | ||||
|                     <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"> | ||||
|                       <tbody> | ||||
|                       <tr> | ||||
|                         <td style="vertical-align: top; padding: 0"> | ||||
|                           <table | ||||
|                             border="0" | ||||
|                             cellpadding="0" | ||||
|                             cellspacing="0" | ||||
|                             role="presentation" | ||||
|                             width="100%" | ||||
|                           > | ||||
|                         <td style="vertical-align:top;padding:0;"> | ||||
|                           <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"> | ||||
|                             <tbody> | ||||
|                               <tr> | ||||
|                                 <td | ||||
|                                   align="center" | ||||
|                                   style=" | ||||
|                             <tr> | ||||
|                               <td align="center" style=" | ||||
|                                     font-size: 0px; | ||||
|                                     padding: 10px 25px; | ||||
|                                     padding-top: 0px; | ||||
|                                     padding-bottom: 0px; | ||||
|                                     word-break: break-word; | ||||
|                                   " | ||||
|                                 > | ||||
|                                   <div | ||||
|                                     style=" | ||||
|                                       font-family: Arial, sans-serif; | ||||
|                                   "> | ||||
|                                 <div style=" | ||||
|                                       font-family: Ubuntu, Helvetica, Arial, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 11px; | ||||
|                                       letter-spacing: normal; | ||||
|                                       line-height: 22px; | ||||
|                                       text-align: center; | ||||
|                                       color: #000000; | ||||
|                                     " | ||||
|                                   > | ||||
|                                     <p style="margin: 10px 0"> | ||||
|                                       This e-mail has been sent to {{name}}, | ||||
|                                       <a | ||||
|                                         href="{{unsubscribeLink}}" | ||||
|                                         style=" | ||||
|                                     "> | ||||
|                                   <p style="margin: 10px 0"> | ||||
|                                     This e-mail has been sent to {{name}}, | ||||
|                                     <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " | ||||
|                                         target="_blank" | ||||
|                                         >click here to unsubscribe</a | ||||
|                                       >. | ||||
|                                     </p> | ||||
|                                   </div> | ||||
|                                 </td> | ||||
|                               </tr> | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                   </p> | ||||
|                                 </div> | ||||
|                               </td> | ||||
|                             </tr> | ||||
|                             <tr> | ||||
|                               <td align="center" | ||||
|                                   style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                                 <div | ||||
|                                   style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> | ||||
|                                 </div> | ||||
|                               </td> | ||||
|                             </tr> | ||||
|                             </tbody> | ||||
|                           </table> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                     </tbody> | ||||
|                   </table> | ||||
|                 </div> | ||||
|                 <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
|                       </tbody> | ||||
|                     </table> | ||||
|                   </div> | ||||
|                   <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               </tbody> | ||||
|             </table> | ||||
|           </div> | ||||
|           <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|   </div> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | @ -214,10 +214,12 @@ | |||
|                                                                 <div | ||||
|                                                                     style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> | ||||
|                                                                     <p style="margin: 10px 0;">This e-mail has been sent | ||||
|                                                                         to {{name}}, <a href="{{unsubscribeLink}}" | ||||
|                                                                             style="color:inherit;text-decoration:none;" | ||||
|                                                                             target="_blank">click here to | ||||
|                                                                             unsubscribe</a>.</p> | ||||
|                                                                         to {{name}}, | ||||
|                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                                                     </p> | ||||
|                                                                 </div> | ||||
|                                                             </td> | ||||
|                                                         </tr> | ||||
|  |  | |||
|  | @ -137,7 +137,7 @@ | |||
|                             style="line-height: 24px;  margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" | ||||
|                             data-testid="4XoHRGw1Y"><span | ||||
|                               style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> | ||||
|                               Welcome! Manifold Markets is a play-money prediction market platform where you can bet on | ||||
|                               Welcome! Manifold Markets is a play-money prediction market platform where you can predict | ||||
|                               anything, from elections to Elon Musk to scientific papers to the NBA. </span></p> | ||||
|                         </div> | ||||
|                       </td> | ||||
|  | @ -286,9 +286,12 @@ | |||
|                                           style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> | ||||
|                                           <div | ||||
|                                             style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> | ||||
|                                             <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a | ||||
|                                                 href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;" | ||||
|                                                 target="_blank">click here to unsubscribe</a>.</p> | ||||
|                                             <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, | ||||
|                                               <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " target="_blank">click here to manage your notifications</a>. | ||||
|                                             </p> | ||||
|                                           </div> | ||||
|                                         </td> | ||||
|                                       </tr> | ||||
|  |  | |||
|  | @ -1,10 +1,12 @@ | |||
| import { DOMAIN } from '../../common/envs/constants' | ||||
| import { Answer } from '../../common/answer' | ||||
| import { Bet } from '../../common/bet' | ||||
| import { getProbability } from '../../common/calculate' | ||||
| import { Comment } from '../../common/comment' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { PrivateUser, User } from '../../common/user' | ||||
| import { | ||||
|   notification_subscription_types, | ||||
|   PrivateUser, | ||||
|   User, | ||||
| } from '../../common/user' | ||||
| import { | ||||
|   formatLargeNumber, | ||||
|   formatMoney, | ||||
|  | @ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm' | |||
| import { formatNumericProbability } from '../../common/pseudo-numeric' | ||||
| 
 | ||||
| import { sendTemplateEmail, sendTextEmail } from './send-email' | ||||
| import { getPrivateUser, getUser } from './utils' | ||||
| import { getFunctionUrl } from '../../common/api' | ||||
| import { richTextToString } from '../../common/util/parse' | ||||
| import { getUser } from './utils' | ||||
| import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' | ||||
| 
 | ||||
| const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') | ||||
| import { | ||||
|   notification_reason_types, | ||||
|   getDestinationsForUser, | ||||
| } from '../../common/notification' | ||||
| 
 | ||||
| export const sendMarketResolutionEmail = async ( | ||||
|   userId: string, | ||||
|   reason: notification_reason_types, | ||||
|   privateUser: PrivateUser, | ||||
|   investment: number, | ||||
|   payout: number, | ||||
|   creator: User, | ||||
|  | @ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async ( | |||
|   resolutionProbability?: number, | ||||
|   resolutions?: { [outcome: string]: number } | ||||
| ) => { | ||||
|   const privateUser = await getPrivateUser(userId) | ||||
|   if ( | ||||
|     !privateUser || | ||||
|     privateUser.unsubscribedFromResolutionEmails || | ||||
|     !privateUser.email | ||||
|   ) | ||||
|     return | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   if (!privateUser || !privateUser.email || !sendToEmail) return | ||||
| 
 | ||||
|   const user = await getUser(userId) | ||||
|   const user = await getUser(privateUser.id) | ||||
|   if (!user) return | ||||
| 
 | ||||
|   const outcome = toDisplayResolution( | ||||
|  | @ -53,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 <david@manifold.markets>', | ||||
|  | @ -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 <david@manifold.markets>', | ||||
|  | @ -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 <david@manifold.markets>', | ||||
|  | @ -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 <no-reply@manifold.markets>` | ||||
| 
 | ||||
|   if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { | ||||
|     const answerNumber = `#${answerId}` | ||||
|     const answerNumber = answerId ? `#${answerId}` : '' | ||||
| 
 | ||||
|     return await sendTemplateEmail( | ||||
|       privateUser.email, | ||||
|  | @ -391,7 +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} <info@manifold.markets>` | ||||
|  | @ -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 <no-reply@manifold.markets>`, | ||||
|     } | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ import * as admin from 'firebase-admin' | |||
| 
 | ||||
| import { Contract } from '../../common/contract' | ||||
| import { getPrivateUser, getUserByUsername } from './utils' | ||||
| import { sendMarketCloseEmail } from './emails' | ||||
| import { createNotification } from './create-notification' | ||||
| 
 | ||||
| export const marketCloseNotifications = functions | ||||
|  | @ -56,7 +55,6 @@ async function sendMarketCloseEmails() { | |||
|     const privateUser = await getPrivateUser(user.id) | ||||
|     if (!privateUser) continue | ||||
| 
 | ||||
|     await sendMarketCloseEmail(user, privateUser, contract) | ||||
|     await createNotification( | ||||
|       contract.id, | ||||
|       'contract', | ||||
|  |  | |||
|  | @ -1,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<ContractComment>( | ||||
|       firestore.collection('contracts').doc(contractId).collection('comments') | ||||
|     ) | ||||
|     const relatedSourceType = comment.replyToCommentId | ||||
|       ? 'comment' | ||||
|       : comment.answerOutcome | ||||
|     const repliedToType = answer | ||||
|       ? 'answer' | ||||
|       : comment.replyToCommentId | ||||
|       ? 'comment' | ||||
|       : undefined | ||||
| 
 | ||||
|     const repliedUserId = comment.replyToCommentId | ||||
|       ? 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 | ||||
|         ) | ||||
|       ) | ||||
|     ) | ||||
|   }) | ||||
|  |  | |||
|  | @ -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 | ||||
|     ) | ||||
|   }) | ||||
|  |  | |||
|  | @ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore | |||
|     if (!contractUpdater) throw new Error('Could not find contract updater') | ||||
| 
 | ||||
|     const previousValue = change.before.data() as Contract | ||||
|     if (previousValue.isResolved !== contract.isResolved) { | ||||
|       let resolutionText = contract.resolution ?? contract.question | ||||
|       if (contract.outcomeType === 'FREE_RESPONSE') { | ||||
|         const answerText = contract.answers.find( | ||||
|           (answer) => answer.id === contract.resolution | ||||
|         )?.text | ||||
|         if (answerText) resolutionText = answerText | ||||
|       } else if (contract.outcomeType === 'BINARY') { | ||||
|         if (resolutionText === 'MKT' && contract.resolutionProbability) | ||||
|           resolutionText = `${contract.resolutionProbability}%` | ||||
|         else if (resolutionText === 'MKT') resolutionText = 'PROB' | ||||
|       } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { | ||||
|         if (resolutionText === 'MKT' && contract.resolutionValue) | ||||
|           resolutionText = `${contract.resolutionValue}` | ||||
|       } | ||||
| 
 | ||||
|       await createCommentOrAnswerOrUpdatedContractNotification( | ||||
|         contract.id, | ||||
|         'contract', | ||||
|         'resolved', | ||||
|         contractUpdater, | ||||
|         eventId, | ||||
|         resolutionText, | ||||
|         contract | ||||
|       ) | ||||
|     } else if ( | ||||
|     if ( | ||||
|       previousValue.closeTime !== contract.closeTime || | ||||
|       previousValue.question !== contract.question | ||||
|     ) { | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| import { difference, mapValues, groupBy, sumBy } from 'lodash' | ||||
| import { mapValues, groupBy, sumBy } from 'lodash' | ||||
| 
 | ||||
| import { | ||||
|   Contract, | ||||
|  | @ -8,10 +8,8 @@ import { | |||
|   MultipleChoiceContract, | ||||
|   RESOLUTIONS, | ||||
| } from '../../common/contract' | ||||
| import { User } from '../../common/user' | ||||
| import { Bet } from '../../common/bet' | ||||
| import { getUser, isProd, payUser } from './utils' | ||||
| import { sendMarketResolutionEmail } from './emails' | ||||
| import { | ||||
|   getLoanPayouts, | ||||
|   getPayouts, | ||||
|  | @ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' | |||
| import { LiquidityProvision } from '../../common/liquidity-provision' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| import { getContractBetMetrics } from '../../common/calculate' | ||||
| import { floatingEqual } from '../../common/util/math' | ||||
| import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|   contractId: z.string(), | ||||
|  | @ -163,15 +161,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 | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										30
									
								
								functions/src/scripts/create-new-notification-preferences.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								functions/src/scripts/create-new-notification-preferences.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| 
 | ||||
| import { initAdmin } from './script-init' | ||||
| import { getDefaultNotificationSettings } from 'common/user' | ||||
| import { getAllPrivateUsers, isProd } from 'functions/src/utils' | ||||
| initAdmin() | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| async function main() { | ||||
|   const privateUsers = await getAllPrivateUsers() | ||||
|   const disableEmails = !isProd() | ||||
|   await Promise.all( | ||||
|     privateUsers.map((privateUser) => { | ||||
|       if (!privateUser.id) return Promise.resolve() | ||||
|       return firestore | ||||
|         .collection('private-users') | ||||
|         .doc(privateUser.id) | ||||
|         .update({ | ||||
|           notificationSubscriptionTypes: getDefaultNotificationSettings( | ||||
|             privateUser.id, | ||||
|             privateUser, | ||||
|             disableEmails | ||||
|           ), | ||||
|         }) | ||||
|     }) | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| if (require.main === module) main().then(() => process.exit()) | ||||
|  | @ -3,7 +3,7 @@ import * as admin from 'firebase-admin' | |||
| import { initAdmin } from './script-init' | ||||
| 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) { | ||||
|  |  | |||
|  | @ -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<User>(firestore.collection('users')), | ||||
|     getValues<Contract>(firestore.collection('contracts')), | ||||
|     getValues<Bet>(firestore.collectionGroup('bets')), | ||||
|     getValues<PortfolioMetrics>( | ||||
|       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<User>(firestore.collection('users')), | ||||
|       getValues<Contract>(firestore.collection('contracts')), | ||||
|       getValues<Bet>(firestore.collectionGroup('bets')), | ||||
|       getValues<PortfolioMetrics>( | ||||
|         firestore | ||||
|           .collectionGroup('portfolioHistory') | ||||
|           .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
 | ||||
|       ), | ||||
|       getValues<Group>(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 } | ||||
|  |  | |||
|  | @ -1,236 +0,0 @@ | |||
| import { useUser } from 'web/hooks/use-user' | ||||
| import React, { useEffect, useState } from 'react' | ||||
| import { notification_subscribe_types, PrivateUser } from 'common/user' | ||||
| import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' | ||||
| import toast from 'react-hot-toast' | ||||
| import { track } from '@amplitude/analytics-browser' | ||||
| import { LoadingIndicator } from 'web/components/loading-indicator' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import clsx from 'clsx' | ||||
| import { CheckIcon, XIcon } from '@heroicons/react/outline' | ||||
| import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { FollowMarketModal } from 'web/components/contract/follow-market-modal' | ||||
| 
 | ||||
| export function NotificationSettings() { | ||||
|   const user = useUser() | ||||
|   const [notificationSettings, setNotificationSettings] = | ||||
|     useState<notification_subscribe_types>('all') | ||||
|   const [emailNotificationSettings, setEmailNotificationSettings] = | ||||
|     useState<notification_subscribe_types>('all') | ||||
|   const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) | ||||
|   const [showModal, setShowModal] = useState(false) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (user) listenForPrivateUser(user.id, setPrivateUser) | ||||
|   }, [user]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!privateUser) return | ||||
|     if (privateUser.notificationPreferences) { | ||||
|       setNotificationSettings(privateUser.notificationPreferences) | ||||
|     } | ||||
|     if ( | ||||
|       privateUser.unsubscribedFromResolutionEmails && | ||||
|       privateUser.unsubscribedFromCommentEmails && | ||||
|       privateUser.unsubscribedFromAnswerEmails | ||||
|     ) { | ||||
|       setEmailNotificationSettings('none') | ||||
|     } else if ( | ||||
|       !privateUser.unsubscribedFromResolutionEmails && | ||||
|       !privateUser.unsubscribedFromCommentEmails && | ||||
|       !privateUser.unsubscribedFromAnswerEmails | ||||
|     ) { | ||||
|       setEmailNotificationSettings('all') | ||||
|     } else { | ||||
|       setEmailNotificationSettings('less') | ||||
|     } | ||||
|   }, [privateUser]) | ||||
| 
 | ||||
|   const loading = 'Changing Notifications Settings' | ||||
|   const success = 'Notification Settings Changed!' | ||||
|   function changeEmailNotifications(newValue: notification_subscribe_types) { | ||||
|     if (!privateUser) return | ||||
|     if (newValue === 'all') { | ||||
|       toast.promise( | ||||
|         updatePrivateUser(privateUser.id, { | ||||
|           unsubscribedFromResolutionEmails: false, | ||||
|           unsubscribedFromCommentEmails: false, | ||||
|           unsubscribedFromAnswerEmails: false, | ||||
|         }), | ||||
|         { | ||||
|           loading, | ||||
|           success, | ||||
|           error: (err) => `${err.message}`, | ||||
|         } | ||||
|       ) | ||||
|     } else if (newValue === 'less') { | ||||
|       toast.promise( | ||||
|         updatePrivateUser(privateUser.id, { | ||||
|           unsubscribedFromResolutionEmails: false, | ||||
|           unsubscribedFromCommentEmails: true, | ||||
|           unsubscribedFromAnswerEmails: true, | ||||
|         }), | ||||
|         { | ||||
|           loading, | ||||
|           success, | ||||
|           error: (err) => `${err.message}`, | ||||
|         } | ||||
|       ) | ||||
|     } else if (newValue === 'none') { | ||||
|       toast.promise( | ||||
|         updatePrivateUser(privateUser.id, { | ||||
|           unsubscribedFromResolutionEmails: true, | ||||
|           unsubscribedFromCommentEmails: true, | ||||
|           unsubscribedFromAnswerEmails: true, | ||||
|         }), | ||||
|         { | ||||
|           loading, | ||||
|           success, | ||||
|           error: (err) => `${err.message}`, | ||||
|         } | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   function changeInAppNotificationSettings( | ||||
|     newValue: notification_subscribe_types | ||||
|   ) { | ||||
|     if (!privateUser) return | ||||
|     track('In-App Notification Preferences Changed', { | ||||
|       newPreference: newValue, | ||||
|       oldPreference: privateUser.notificationPreferences, | ||||
|     }) | ||||
|     toast.promise( | ||||
|       updatePrivateUser(privateUser.id, { | ||||
|         notificationPreferences: newValue, | ||||
|       }), | ||||
|       { | ||||
|         loading, | ||||
|         success, | ||||
|         error: (err) => `${err.message}`, | ||||
|       } | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (privateUser && privateUser.notificationPreferences) | ||||
|       setNotificationSettings(privateUser.notificationPreferences) | ||||
|     else setNotificationSettings('all') | ||||
|   }, [privateUser]) | ||||
| 
 | ||||
|   if (!privateUser) { | ||||
|     return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> | ||||
|   } | ||||
| 
 | ||||
|   function NotificationSettingLine(props: { | ||||
|     label: string | React.ReactNode | ||||
|     highlight: boolean | ||||
|     onClick?: () => void | ||||
|   }) { | ||||
|     const { label, highlight, onClick } = props | ||||
|     return ( | ||||
|       <Row | ||||
|         className={clsx( | ||||
|           'my-1 gap-1 text-gray-300', | ||||
|           highlight && '!text-black', | ||||
|           onClick ? 'cursor-pointer' : '' | ||||
|         )} | ||||
|         onClick={onClick} | ||||
|       > | ||||
|         {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} | ||||
|         {label} | ||||
|       </Row> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={'p-2'}> | ||||
|       <div>In App Notifications</div> | ||||
|       <ChoicesToggleGroup | ||||
|         currentChoice={notificationSettings} | ||||
|         choicesMap={{ All: 'all', Less: 'less', None: 'none' }} | ||||
|         setChoice={(choice) => | ||||
|           changeInAppNotificationSettings( | ||||
|             choice as notification_subscribe_types | ||||
|           ) | ||||
|         } | ||||
|         className={'col-span-4 p-2'} | ||||
|         toggleClassName={'w-24'} | ||||
|       /> | ||||
|       <div className={'mt-4 text-sm'}> | ||||
|         <Col className={''}> | ||||
|           <Row className={'my-1'}> | ||||
|             You will receive notifications for these general events: | ||||
|           </Row> | ||||
|           <NotificationSettingLine | ||||
|             highlight={notificationSettings !== 'none'} | ||||
|             label={"Income & referral bonuses you've received"} | ||||
|           /> | ||||
|           <Row className={'my-1'}> | ||||
|             You will receive new comment, answer, & resolution notifications on | ||||
|             questions: | ||||
|           </Row> | ||||
|           <NotificationSettingLine | ||||
|             highlight={notificationSettings !== 'none'} | ||||
|             label={ | ||||
|               <span> | ||||
|                 That <span className={'font-bold'}>you watch </span>- you | ||||
|                 auto-watch questions if: | ||||
|               </span> | ||||
|             } | ||||
|             onClick={() => setShowModal(true)} | ||||
|           /> | ||||
|           <Col | ||||
|             className={clsx( | ||||
|               'mb-2 ml-8', | ||||
|               'gap-1 text-gray-300', | ||||
|               notificationSettings !== 'none' && '!text-black' | ||||
|             )} | ||||
|           > | ||||
|             <Row>• You create it</Row> | ||||
|             <Row>• You bet, comment on, or answer it</Row> | ||||
|             <Row>• You add liquidity to it</Row> | ||||
|             <Row> | ||||
|               • If you select 'Less' and you've commented on or answered a | ||||
|               question, you'll only receive notification on direct replies to | ||||
|               your comments or answers | ||||
|             </Row> | ||||
|           </Col> | ||||
|         </Col> | ||||
|       </div> | ||||
|       <div className={'mt-4'}>Email Notifications</div> | ||||
|       <ChoicesToggleGroup | ||||
|         currentChoice={emailNotificationSettings} | ||||
|         choicesMap={{ All: 'all', Less: 'less', None: 'none' }} | ||||
|         setChoice={(choice) => | ||||
|           changeEmailNotifications(choice as notification_subscribe_types) | ||||
|         } | ||||
|         className={'col-span-4 p-2'} | ||||
|         toggleClassName={'w-24'} | ||||
|       /> | ||||
|       <div className={'mt-4 text-sm'}> | ||||
|         <div> | ||||
|           You will receive emails for: | ||||
|           <NotificationSettingLine | ||||
|             label={"Resolution of questions you're betting on"} | ||||
|             highlight={emailNotificationSettings !== 'none'} | ||||
|           /> | ||||
|           <NotificationSettingLine | ||||
|             label={'Closure of your questions'} | ||||
|             highlight={emailNotificationSettings !== 'none'} | ||||
|           /> | ||||
|           <NotificationSettingLine | ||||
|             label={'Activity on your questions'} | ||||
|             highlight={emailNotificationSettings === 'all'} | ||||
|           /> | ||||
|           <NotificationSettingLine | ||||
|             label={"Activity on questions you've answered or commented on"} | ||||
|             highlight={emailNotificationSettings === 'all'} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <FollowMarketModal setOpen={setShowModal} open={showModal} /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | @ -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: { | |||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|             <Row className={'justify-end'}> | ||||
|               {hasZeroBetAnswers && !showAllAnswers && ( | ||||
|                 <Button | ||||
|                   color={'gray-white'} | ||||
|                   onClick={() => setShowAllAnswers(true)} | ||||
|                   size={'md'} | ||||
|                 > | ||||
|                   Show More | ||||
|                 </Button> | ||||
|               )} | ||||
|             </Row> | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <div | ||||
|       className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`} | ||||
|       className={`${colorClass} mb-2 mr-auto self-center text-xs font-medium tracking-wide`} | ||||
|     > | ||||
|       {text} | ||||
|     </div> | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <DragDropContext | ||||
|  | @ -35,23 +29,16 @@ export function ArrangeHome(props: { | |||
| 
 | ||||
|         const item = itemsById[draggableId] | ||||
| 
 | ||||
|         const newHomeSections = { | ||||
|           visible: visibleItems.map((item) => 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) | ||||
|       }} | ||||
|     > | ||||
|       <Row className="relative max-w-lg gap-4"> | ||||
|         <DraggableList items={visibleItems} title="Visible" /> | ||||
|         <DraggableList items={hiddenItems} title="Hidden" /> | ||||
|       <Row className="relative max-w-md gap-4"> | ||||
|         <DraggableList items={sections} title="Sections" /> | ||||
|       </Row> | ||||
|     </DragDropContext> | ||||
|   ) | ||||
|  | @ -64,16 +51,13 @@ function DraggableList(props: { | |||
|   const { title, items } = props | ||||
|   return ( | ||||
|     <Droppable droppableId={title.toLowerCase()}> | ||||
|       {(provided, snapshot) => ( | ||||
|       {(provided) => ( | ||||
|         <Col | ||||
|           {...provided.droppableProps} | ||||
|           ref={provided.innerRef} | ||||
|           className={clsx( | ||||
|             'width-[220px] flex-1 items-start rounded bg-gray-50 p-2', | ||||
|             snapshot.isDraggingOver && 'bg-gray-100' | ||||
|           )} | ||||
|           className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')} | ||||
|         > | ||||
|           <Subtitle text={title} className="mx-2 !my-2" /> | ||||
|           <Subtitle text={title} className="mx-2 !mt-0 !mb-4" /> | ||||
|           {items.map((item, index) => ( | ||||
|             <Draggable key={item.id} draggableId={item.id} index={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' | ||||
|                   )} | ||||
|                 > | ||||
|                   <MenuIcon | ||||
|                     className="h-5 w-5 flex-shrink-0 text-gray-500" | ||||
|                     aria-hidden="true" | ||||
|                   />{' '} | ||||
|                   {item.label} | ||||
|                   <SectionItem | ||||
|                     className={clsx( | ||||
|                       snapshot.isDragging && 'z-[9000] bg-gray-200' | ||||
|                     )} | ||||
|                     item={item} | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|             </Draggable> | ||||
|  | @ -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 ( | ||||
|     <div | ||||
|       className={clsx( | ||||
|         className, | ||||
|         'flex flex-row items-center gap-4 rounded bg-gray-50 p-2' | ||||
|       )} | ||||
|     > | ||||
|       <MenuIcon | ||||
|         className="h-5 w-5 flex-shrink-0 text-gray-500" | ||||
|         aria-hidden="true" | ||||
|       />{' '} | ||||
|       {item.label} | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| 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, | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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: { | |||
|             /> | ||||
|           </> | ||||
|         ) : ( | ||||
|           <> | ||||
|             <BetSignUpPrompt /> | ||||
|             <PlayMoneyDisclaimer /> | ||||
|           </> | ||||
|           <MarketIntroPanel /> | ||||
|         )} | ||||
|       </Col> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 <LoadingIndicator /> | ||||
| 
 | ||||
|   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 <div className="px-4 text-gray-500">None</div> | ||||
| 
 | ||||
|   return ( | ||||
|     <Col> | ||||
|       <Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0"> | ||||
|         <Col className="flex-1 divide-y"> | ||||
|           {filteredChanges.slice(0, count / 2).map((contract) => ( | ||||
|             <Row className="items-center hover:bg-gray-100"> | ||||
|               <ProbChange | ||||
|                 className="p-4 text-right text-xl" | ||||
|                 contract={contract} | ||||
|               /> | ||||
|               <SiteLink | ||||
|                 className="p-4 pl-2 font-semibold text-indigo-700" | ||||
|                 href={contractPath(contract)} | ||||
|               > | ||||
|                 <span className="line-clamp-2">{contract.question}</span> | ||||
|               </SiteLink> | ||||
|             </Row> | ||||
|           ))} | ||||
|         </Col> | ||||
|         <Col className="flex-1 divide-y"> | ||||
|           {filteredChanges.slice(count / 2).map((contract) => ( | ||||
|             <Row className="items-center hover:bg-gray-100"> | ||||
|               <ProbChange | ||||
|                 className="p-4 text-right text-xl" | ||||
|                 contract={contract} | ||||
|               /> | ||||
|               <SiteLink | ||||
|                 className="p-4 pl-2 font-semibold text-indigo-700" | ||||
|                 href={contractPath(contract)} | ||||
|               > | ||||
|                 <span className="line-clamp-2">{contract.question}</span> | ||||
|               </SiteLink> | ||||
|             </Row> | ||||
|           ))} | ||||
|         </Col> | ||||
|     <Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0"> | ||||
|       <Col className="flex-1 divide-y"> | ||||
|         {filteredPositiveChanges.map((contract) => ( | ||||
|           <Row className="items-center hover:bg-gray-100"> | ||||
|             <ProbChange | ||||
|               className="p-4 text-right text-xl" | ||||
|               contract={contract} | ||||
|             /> | ||||
|             <SiteLink | ||||
|               className="p-4 pl-2 font-semibold text-indigo-700" | ||||
|               href={contractPath(contract)} | ||||
|             > | ||||
|               <span className="line-clamp-2">{contract.question}</span> | ||||
|             </SiteLink> | ||||
|           </Row> | ||||
|         ))} | ||||
|       </Col> | ||||
|       <Col className="flex-1 divide-y"> | ||||
|         {filteredNegativeChanges.map((contract) => ( | ||||
|           <Row className="items-center hover:bg-gray-100"> | ||||
|             <ProbChange | ||||
|               className="p-4 text-right text-xl" | ||||
|               contract={contract} | ||||
|             /> | ||||
|             <SiteLink | ||||
|               className="p-4 pl-2 font-semibold text-indigo-700" | ||||
|               href={contractPath(contract)} | ||||
|             > | ||||
|               <span className="line-clamp-2">{contract.question}</span> | ||||
|             </SiteLink> | ||||
|           </Row> | ||||
|         ))} | ||||
|       </Col> | ||||
|       <div | ||||
|         className={clsx(linkClass, 'cursor-pointer self-end')} | ||||
|         onClick={() => setExpanded(!expanded)} | ||||
|       > | ||||
|         {expanded ? 'Show less' : 'Show more'} | ||||
|       </div> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline' | |||
| import React from 'react' | ||||
| import clsx from 'clsx' | ||||
| 
 | ||||
| export const FollowMarketModal = (props: { | ||||
| export const WatchMarketModal = (props: { | ||||
|   open: boolean | ||||
|   setOpen: (b: boolean) => void | ||||
|   title?: string | ||||
|  | @ -18,20 +18,21 @@ export const FollowMarketModal = (props: { | |||
|         <Col className={'gap-2'}> | ||||
|           <span className={'text-indigo-700'}>• What is watching?</span> | ||||
|           <span className={'ml-2'}> | ||||
|             You can receive notifications on questions you're interested in by | ||||
|             You'll receive notifications on markets by betting, commenting, or | ||||
|             clicking the | ||||
|             <EyeIcon | ||||
|               className={clsx('ml-1 inline h-6 w-6 align-top')} | ||||
|               aria-hidden="true" | ||||
|             /> | ||||
|             ️ button on a question. | ||||
|             ️ button on them. | ||||
|           </span> | ||||
|           <span className={'text-indigo-700'}> | ||||
|             • What types of notifications will I receive? | ||||
|           </span> | ||||
|           <span className={'ml-2'}> | ||||
|             You'll receive in-app notifications for new comments, answers, and | ||||
|             updates to the question. | ||||
|             You'll receive notifications for new comments, answers, and updates | ||||
|             to the question. See the notifications settings pages to customize | ||||
|             which types of notifications you receive on watched markets. | ||||
|           </span> | ||||
|         </Col> | ||||
|       </Col> | ||||
|  | @ -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 /^<iframe.*<\/iframe>$/.test(text) | ||||
| } | ||||
| 
 | ||||
| function FloatingMenu(props: { editor: Editor | null }) { | ||||
|   const { editor } = props | ||||
| 
 | ||||
|   const [url, setUrl] = useState<string | null>(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 ( | ||||
|     <BubbleMenu | ||||
|       editor={editor} | ||||
|       className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white" | ||||
|     > | ||||
|       {url === null ? ( | ||||
|         <> | ||||
|           <button onClick={() => editor.chain().focus().toggleBold().run()}> | ||||
|             <BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} /> | ||||
|           </button> | ||||
|           <button onClick={() => editor.chain().focus().toggleItalic().run()}> | ||||
|             <ItalicIcon | ||||
|               className={clsx('h-5', isItalic && 'text-indigo-200')} | ||||
|             /> | ||||
|           </button> | ||||
|           <button onClick={() => (isLink ? unsetLink() : setUrl(''))}> | ||||
|             <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> | ||||
|           </button> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           <input | ||||
|             type="text" | ||||
|             className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0" | ||||
|             placeholder="Type or paste a link" | ||||
|             onChange={(e) => setUrl(e.target.value)} | ||||
|           /> | ||||
|           <button onClick={() => (setLink(), setUrl(null))}> | ||||
|             <CheckIcon className="h-5 w-5" /> | ||||
|           </button> | ||||
|           <button onClick={() => (unsetLink(), setUrl(null))}> | ||||
|             <TrashIcon className="h-5 w-5" /> | ||||
|           </button> | ||||
|         </> | ||||
|       )} | ||||
|     </BubbleMenu> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function TextEditor(props: { | ||||
|   editor: Editor | null | ||||
|   upload: ReturnType<typeof useUploadMutation> | ||||
|  | @ -155,6 +222,7 @@ export function TextEditor(props: { | |||
|       {/* hide placeholder when focused */} | ||||
|       <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> | ||||
|         <div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> | ||||
|           <FloatingMenu editor={editor} /> | ||||
|           <EditorContent editor={editor} /> | ||||
|           {/* Toolbar, with buttons for images and embeds */} | ||||
|           <div className="flex h-9 items-center gap-5 pl-4 pr-1"> | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <Col> | ||||
|       <CommentBetArea | ||||
|         betsByCurrentUser={props.betsByCurrentUser} | ||||
|         contract={props.contract} | ||||
|         commentsByCurrentUser={props.commentsByCurrentUser} | ||||
|         parentAnswerOutcome={props.parentAnswerOutcome} | ||||
|         user={useUser()} | ||||
|         className={props.className} | ||||
|         mostRecentCommentableBet={mostRecentCommentableBet} | ||||
|       /> | ||||
|       <CommentInput | ||||
|         replyToUser={props.replyToUser} | ||||
|         parentAnswerOutcome={props.parentAnswerOutcome} | ||||
|         parentCommentId={props.parentCommentId} | ||||
|         onSubmitComment={onSubmitComment} | ||||
|         className={props.className} | ||||
|         presetId={id} | ||||
|       /> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|     <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}> | ||||
|       <div className="mb-1 text-gray-500"> | ||||
|         {mostRecentCommentableBet && ( | ||||
|           <BetStatusText | ||||
|             contract={contract} | ||||
|             bet={mostRecentCommentableBet} | ||||
|             isSelf={true} | ||||
|             hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'} | ||||
|           /> | ||||
|         )} | ||||
|         {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( | ||||
|           <> | ||||
|             {"You're"} | ||||
|             <CommentStatus | ||||
|               outcome={outcome} | ||||
|               contract={contract} | ||||
|               prob={ | ||||
|                 contract.outcomeType === 'BINARY' | ||||
|                   ? getProbability(contract) | ||||
|                   : undefined | ||||
|               } | ||||
|             /> | ||||
|           </> | ||||
|         )} | ||||
|       </div> | ||||
|     </Row> | ||||
|     <CommentInput | ||||
|       replyToUser={props.replyToUser} | ||||
|       parentAnswerOutcome={props.parentAnswerOutcome} | ||||
|       parentCommentId={props.parentCommentId} | ||||
|       onSubmitComment={onSubmitComment} | ||||
|       className={props.className} | ||||
|       presetId={id} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ import { User } from 'common/user' | |||
| import { useContractFollows } from 'web/hooks/use-follows' | ||||
| import { firebaseLogin, updateUser } from 'web/lib/firebase/users' | ||||
| import { track } from 'web/lib/service/analytics' | ||||
| import { FollowMarketModal } from 'web/components/contract/follow-market-modal' | ||||
| import { WatchMarketModal } from 'web/components/contract/watch-market-modal' | ||||
| import { useState } from 'react' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| 
 | ||||
|  | @ -65,7 +65,7 @@ export const FollowMarketButton = (props: { | |||
|           Watch | ||||
|         </Col> | ||||
|       )} | ||||
|       <FollowMarketModal | ||||
|       <WatchMarketModal | ||||
|         open={open} | ||||
|         setOpen={setOpen} | ||||
|         title={`You ${ | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import Image from 'next/future/image' | ||||
| import { SparklesIcon } from '@heroicons/react/solid' | ||||
| 
 | ||||
| import { Contract } from 'common/contract' | ||||
|  | @ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) { | |||
|   return ( | ||||
|     <> | ||||
|       <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> | ||||
|         <img | ||||
|         <Image | ||||
|           height={250} | ||||
|           width={250} | ||||
|           className="self-center" | ||||
|  |  | |||
							
								
								
									
										26
									
								
								web/components/market-intro-panel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								web/components/market-intro-panel.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import Image from 'next/future/image' | ||||
| 
 | ||||
| import { Col } from './layout/col' | ||||
| import { BetSignUpPrompt } from './sign-up-prompt' | ||||
| 
 | ||||
| export function MarketIntroPanel() { | ||||
|   return ( | ||||
|     <Col> | ||||
|       <div className="text-xl">Play-money predictions</div> | ||||
| 
 | ||||
|       <Image | ||||
|         height={150} | ||||
|         width={150} | ||||
|         className="self-center" | ||||
|         src="/flappy-logo.gif" | ||||
|       /> | ||||
| 
 | ||||
|       <div className="mb-4 text-sm"> | ||||
|         Manifold Markets is a play-money prediction market platform where you | ||||
|         can forecast anything. | ||||
|       </div> | ||||
| 
 | ||||
|       <BetSignUpPrompt /> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										320
									
								
								web/components/notification-settings.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										320
									
								
								web/components/notification-settings.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,320 @@ | |||
| import { usePrivateUser } from 'web/hooks/use-user' | ||||
| import React, { ReactNode, useEffect, useState } from 'react' | ||||
| import { LoadingIndicator } from 'web/components/loading-indicator' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import clsx from 'clsx' | ||||
| import { | ||||
|   notification_subscription_types, | ||||
|   notification_destination_types, | ||||
| } from 'common/user' | ||||
| import { updatePrivateUser } from 'web/lib/firebase/users' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { | ||||
|   CashIcon, | ||||
|   ChatIcon, | ||||
|   ChevronDownIcon, | ||||
|   ChevronUpIcon, | ||||
|   CurrencyDollarIcon, | ||||
|   InboxInIcon, | ||||
|   InformationCircleIcon, | ||||
|   LightBulbIcon, | ||||
|   TrendingUpIcon, | ||||
|   UserIcon, | ||||
|   UsersIcon, | ||||
| } from '@heroicons/react/outline' | ||||
| import { WatchMarketModal } from 'web/components/contract/watch-market-modal' | ||||
| import { filterDefined } from 'common/util/array' | ||||
| import toast from 'react-hot-toast' | ||||
| import { SwitchSetting } from 'web/components/switch-setting' | ||||
| 
 | ||||
| export function NotificationSettings(props: { | ||||
|   navigateToSection: string | undefined | ||||
| }) { | ||||
|   const { navigateToSection } = props | ||||
|   const privateUser = usePrivateUser() | ||||
|   const [showWatchModal, setShowWatchModal] = useState(false) | ||||
| 
 | ||||
|   if (!privateUser || !privateUser.notificationSubscriptionTypes) { | ||||
|     return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> | ||||
|   } | ||||
| 
 | ||||
|   const emailsEnabled: Array<keyof notification_subscription_types> = [ | ||||
|     'all_comments_on_watched_markets', | ||||
|     'all_replies_to_my_comments_on_watched_markets', | ||||
|     'all_comments_on_contracts_with_shares_in_on_watched_markets', | ||||
| 
 | ||||
|     'all_answers_on_watched_markets', | ||||
|     'all_replies_to_my_answers_on_watched_markets', | ||||
|     'all_answers_on_contracts_with_shares_in_on_watched_markets', | ||||
| 
 | ||||
|     'your_contract_closed', | ||||
|     'all_comments_on_my_markets', | ||||
|     'all_answers_on_my_markets', | ||||
| 
 | ||||
|     'resolutions_on_watched_markets_with_shares_in', | ||||
|     'resolutions_on_watched_markets', | ||||
| 
 | ||||
|     '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<keyof notification_subscription_types> = [ | ||||
|     'trending_markets', | ||||
|     'profit_loss_updates', | ||||
|     'onboarding_flow', | ||||
|     'thank_you_for_purchases', | ||||
|   ] | ||||
| 
 | ||||
|   type sectionData = { | ||||
|     label: string | ||||
|     subscriptionTypeToDescription: { | ||||
|       [key in keyof Partial<notification_subscription_types>]: string | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const comments: sectionData = { | ||||
|     label: 'New Comments', | ||||
|     subscriptionTypeToDescription: { | ||||
|       all_comments_on_watched_markets: 'All new comments', | ||||
|       all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, | ||||
|       // 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 ( | ||||
|       <Row | ||||
|         className={clsx( | ||||
|           'my-1 gap-1 text-gray-300', | ||||
|           highlight ? 'rounded-md bg-indigo-100 p-1' : '' | ||||
|         )} | ||||
|       > | ||||
|         <Col className="ml-3 gap-2 text-sm"> | ||||
|           <Row className="gap-2 font-medium text-gray-700"> | ||||
|             <span>{description}</span> | ||||
|           </Row> | ||||
|           <Row className={'gap-4'}> | ||||
|             {!browserDisabled.includes(key) && ( | ||||
|               <SwitchSetting | ||||
|                 checked={inAppEnabled} | ||||
|                 onChange={setInAppEnabled} | ||||
|                 label={'Web'} | ||||
|               /> | ||||
|             )} | ||||
|             {emailsEnabled.includes(key) && ( | ||||
|               <SwitchSetting | ||||
|                 checked={emailEnabled} | ||||
|                 onChange={setEmailEnabled} | ||||
|                 label={'Email'} | ||||
|               /> | ||||
|             )} | ||||
|           </Row> | ||||
|         </Col> | ||||
|       </Row> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   const getUsersSavedPreference = ( | ||||
|     key: keyof notification_subscription_types | ||||
|   ) => { | ||||
|     return privateUser.notificationSubscriptionTypes[key] ?? [] | ||||
|   } | ||||
| 
 | ||||
|   const Section = (icon: ReactNode, data: sectionData) => { | ||||
|     const { label, subscriptionTypeToDescription } = data | ||||
|     const expand = | ||||
|       navigateToSection && | ||||
|       Object.keys(subscriptionTypeToDescription).includes(navigateToSection) | ||||
|     const [expanded, setExpanded] = useState(expand) | ||||
| 
 | ||||
|     // Not working as the default value for expanded, so using a useEffect
 | ||||
|     useEffect(() => { | ||||
|       if (expand) setExpanded(true) | ||||
|     }, [expand]) | ||||
| 
 | ||||
|     return ( | ||||
|       <Col className={clsx('ml-2 gap-2')}> | ||||
|         <Row | ||||
|           className={'mt-1 cursor-pointer items-center gap-2 text-gray-600'} | ||||
|           onClick={() => setExpanded(!expanded)} | ||||
|         > | ||||
|           {icon} | ||||
|           <span>{label}</span> | ||||
| 
 | ||||
|           {expanded ? ( | ||||
|             <ChevronUpIcon className="h-5 w-5 text-xs text-gray-500"> | ||||
|               Hide | ||||
|             </ChevronUpIcon> | ||||
|           ) : ( | ||||
|             <ChevronDownIcon className="h-5 w-5 text-xs text-gray-500"> | ||||
|               Show | ||||
|             </ChevronDownIcon> | ||||
|           )} | ||||
|         </Row> | ||||
|         <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> | ||||
|           {Object.entries(subscriptionTypeToDescription).map(([key, value]) => | ||||
|             NotificationSettingLine( | ||||
|               value, | ||||
|               key as keyof notification_subscription_types, | ||||
|               getUsersSavedPreference( | ||||
|                 key as keyof notification_subscription_types | ||||
|               ) | ||||
|             ) | ||||
|           )} | ||||
|         </Col> | ||||
|       </Col> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={'p-2'}> | ||||
|       <Col className={'gap-6'}> | ||||
|         <Row className={'gap-2 text-xl text-gray-700'}> | ||||
|           <span>Notifications for Watched Markets</span> | ||||
|           <InformationCircleIcon | ||||
|             className="-mb-1 h-5 w-5 cursor-pointer text-gray-500" | ||||
|             onClick={() => setShowWatchModal(true)} | ||||
|           /> | ||||
|         </Row> | ||||
|         {Section(<ChatIcon className={'h-6 w-6'} />, comments)} | ||||
|         {Section(<LightBulbIcon className={'h-6 w-6'} />, answers)} | ||||
|         {Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)} | ||||
|         {Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)} | ||||
|         <Row className={'gap-2 text-xl text-gray-700'}> | ||||
|           <span>Balance Changes</span> | ||||
|         </Row> | ||||
|         {Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)} | ||||
|         {Section(<CashIcon className={'h-6 w-6'} />, otherBalances)} | ||||
|         <Row className={'gap-2 text-xl text-gray-700'}> | ||||
|           <span>General</span> | ||||
|         </Row> | ||||
|         {Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)} | ||||
|         {Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)} | ||||
|         <WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} /> | ||||
|       </Col> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										34
									
								
								web/components/switch-setting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								web/components/switch-setting.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| import { Switch } from '@headlessui/react' | ||||
| import clsx from 'clsx' | ||||
| import React from 'react' | ||||
| 
 | ||||
| export const SwitchSetting = (props: { | ||||
|   checked: boolean | ||||
|   onChange: (checked: boolean) => void | ||||
|   label: string | ||||
| }) => { | ||||
|   const { checked, onChange, label } = props | ||||
|   return ( | ||||
|     <Switch.Group as="div" className="flex items-center"> | ||||
|       <Switch | ||||
|         checked={checked} | ||||
|         onChange={onChange} | ||||
|         className={clsx( | ||||
|           checked ? 'bg-indigo-600' : 'bg-gray-200', | ||||
|           'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2' | ||||
|         )} | ||||
|       > | ||||
|         <span | ||||
|           aria-hidden="true" | ||||
|           className={clsx( | ||||
|             checked ? 'translate-x-5' : 'translate-x-0', | ||||
|             'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out' | ||||
|           )} | ||||
|         /> | ||||
|       </Switch> | ||||
|       <Switch.Label as="span" className="ml-3"> | ||||
|         <span className="text-sm font-medium text-gray-900">{label}</span> | ||||
|       </Switch.Label> | ||||
|     </Switch.Group> | ||||
|   ) | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| import { useMemo } from 'react' | ||||
| import { 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 | ||||
| } | ||||
|  |  | |||
|  | @ -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<Group>('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<GroupMemberDoc>(groupMembers(group.id)) | ||||
|   return await Promise.all(members.map((m) => m.userId).map(getUser)) | ||||
|   return members.map((m) => m.userId) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										20
									
								
								web/lib/icons/bold-icon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/lib/icons/bold-icon.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| // from Feather: https://feathericons.com/
 | ||||
| export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) { | ||||
|   return ( | ||||
|     <svg | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       width="24" | ||||
|       height="24" | ||||
|       viewBox="0 0 24 24" | ||||
|       fill="none" | ||||
|       stroke="currentColor" | ||||
|       stroke-width="2" | ||||
|       stroke-linecap="round" | ||||
|       stroke-linejoin="round" | ||||
|       {...props} | ||||
|     > | ||||
|       <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> | ||||
|       <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> | ||||
|     </svg> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										21
									
								
								web/lib/icons/italic-icon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/lib/icons/italic-icon.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| // from Feather: https://feathericons.com/
 | ||||
| export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) { | ||||
|   return ( | ||||
|     <svg | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       width="24" | ||||
|       height="24" | ||||
|       viewBox="0 0 24 24" | ||||
|       fill="none" | ||||
|       stroke="currentColor" | ||||
|       stroke-width="2" | ||||
|       stroke-linecap="round" | ||||
|       stroke-linejoin="round" | ||||
|       {...props} | ||||
|     > | ||||
|       <line x1="19" y1="4" x2="10" y2="4"></line> | ||||
|       <line x1="14" y1="20" x2="5" y2="20"></line> | ||||
|       <line x1="15" y1="4" x2="9" y2="20"></line> | ||||
|     </svg> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										20
									
								
								web/lib/icons/link-icon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/lib/icons/link-icon.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| // from Feather: https://feathericons.com/
 | ||||
| export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) { | ||||
|   return ( | ||||
|     <svg | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       width="24" | ||||
|       height="24" | ||||
|       viewBox="0 0 24 24" | ||||
|       fill="none" | ||||
|       stroke="currentColor" | ||||
|       stroke-width="2" | ||||
|       stroke-linecap="round" | ||||
|       stroke-linejoin="round" | ||||
|       {...props} | ||||
|     > | ||||
|       <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> | ||||
|       <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> | ||||
|     </svg> | ||||
|   ) | ||||
| } | ||||
|  | @ -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 ( | ||||
|     <Page> | ||||
|       <Col className="pm:mx-10 gap-4 px-4 pb-12"> | ||||
|       <Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2"> | ||||
|         <Row className={'w-full items-center justify-between'}> | ||||
|           <Title text="Edit your home page" /> | ||||
|           <DoneButton /> | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user