From de8c27c97086ac9373fecdae00823f2d0dab124d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 07:48:41 -0600 Subject: [PATCH 01/37] Filter None answer earlier --- web/components/answers/answers-panel.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 7ab5e804..444c5701 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -33,15 +33,13 @@ export function AnswersPanel(props: { contract const [showAllAnswers, setShowAllAnswers] = useState(false) - const answers = useAnswers(contract.id) ?? contract.answers - const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0) + 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 [winningAnswers, losingAnswers] = partition( - answers.filter((answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers - ? true - : totalBets[answer.id] > 0 - ), + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) ) From 8b1776fe3b8c7466456feea548d4850d548713b2 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 07:53:01 -0600 Subject: [PATCH 02/37] Remove contracts number badge from groups tab --- web/pages/group/[...slugs]/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f5d68e57..f124e225 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -80,12 +80,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { 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, memberIds, creator, @@ -111,7 +108,6 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { - contractsCount: number group: Group | null memberIds: string[] creator: User @@ -122,7 +118,6 @@ export default function GroupPage(props: { suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { - contractsCount: 0, group: null, memberIds: [], creator: null, @@ -131,8 +126,7 @@ export default function GroupPage(props: { messages: [], suggestedFilter: 'open', } - const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = - props + const { creator, topTraders, topCreators, suggestedFilter } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -208,7 +202,6 @@ export default function GroupPage(props: { const tabs = [ { - badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), From 55b895146b53ccf257698e9c97ca5cd565cfc3d5 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 07:54:37 -0600 Subject: [PATCH 03/37] Find multiple choice resolution texts as well --- functions/src/resolve-market.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 015ac72f..b867b609 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -166,7 +166,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { (bets) => getContractBetMetrics(contract, bets).invested ) let resolutionText = outcome ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { const answerText = contract.answers.find( (answer) => answer.id === outcome )?.text From 2c922cbae6cde42c82f114f84c32c1f5bc153da5 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 08:16:23 -0600 Subject: [PATCH 04/37] Send no-bet resolution emails to those without bets --- .../market-resolved-no-bets.html | 491 ++++++++++++++++++ functions/src/emails.ts | 9 +- 2 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 functions/src/email-templates/market-resolved-no-bets.html diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html new file mode 100644 index 00000000..ff5f541f --- /dev/null +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -0,0 +1,491 @@ + + + + + + + Market resolved + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ + Manifold Markets + +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

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

+ Hi {{name}},

+
+
+
+

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

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

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

+
+
+
+
+ +
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d1387ef9..da6a5b41 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -510,3 +510,37 @@ function contractUrl(contract: Contract) { function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } + +export const sendNewFollowedMarketEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + + return await sendTemplateEmail( + privateUser.email, + `${creatorName} asked ${contract.question}`, + 'new-market-from-followed-user', + { + name: firstName, + creatorName, + unsubscribeUrl, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionImgSrc: imageSourceUrl(contract), + }, + { + from: `${creatorName} on Manifold `, + } + ) +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index d9826f6c..b613142b 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -21,13 +21,11 @@ export const onCreateContract = functions const mentioned = parseMentions(desc) await addUserToContractFollowers(contract.id, contractCreator.id) - await createNotification( - contract.id, - 'contract', - 'created', + await createNewContractNotification( contractCreator, + contract, eventId, richTextToString(desc), - { contract, recipients: mentioned } + mentioned ) }) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c319bf32..65804991 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -58,9 +58,9 @@ export function NotificationSettings(props: { 'onboarding_flow', 'thank_you_for_purchases', + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', // TODO: add these - 'tagged_user', - // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', // 'on_new_follow', @@ -90,6 +90,7 @@ export function NotificationSettings(props: { subscriptionTypeToDescription: { all_comments_on_watched_markets: 'All new comments', all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // TODO: combine these two all_replies_to_my_comments_on_watched_markets: 'Only replies to your comments', all_replies_to_my_answers_on_watched_markets: diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a4c25ed3..7ebc473b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1031,52 +1031,53 @@ function getReasonForShowingNotification( const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string // TODO: we could leave out this switch and just use the reason field now that they have more information - switch (sourceType) { - case 'comment': - if (reason === 'reply_to_users_answer') - reasonText = justSummary ? 'replied' : 'replied to you on' - else if (reason === 'tagged_user') - reasonText = justSummary ? 'tagged you' : 'tagged you on' - else if (reason === 'reply_to_users_comment') - reasonText = justSummary ? 'replied' : 'replied to you on' - else reasonText = justSummary ? `commented` : `commented on` - break - case 'contract': - if (reason === 'contract_from_followed_user') - reasonText = justSummary ? 'asked the question' : 'asked' - else if (sourceUpdateType === 'resolved') - reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` - else reasonText = justSummary ? 'updated the question' : `updated` - break - case 'answer': - if (reason === 'answer_on_your_contract') - reasonText = `answered your question ` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added a subsidy to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - case 'bet': - reasonText = 'bet against you' - break - case 'challenge': - reasonText = 'accepted your challenge' - break - default: - reasonText = '' - } + if (reason === 'tagged_user') + reasonText = justSummary ? 'tagged you' : 'tagged you on' + else + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = justSummary ? 'replied' : 'replied to you on' + else if (reason === 'reply_to_users_comment') + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` + break + case 'contract': + if (reason === 'contract_from_followed_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` + break + case 'answer': + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added a subsidy to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bet': + reasonText = 'bet against you' + break + case 'challenge': + reasonText = 'accepted your challenge' + break + default: + reasonText = '' + } return reasonText } From c42332627036446869412917d7f22899b5e96ec0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 16:12:53 -0600 Subject: [PATCH 06/37] Send users emails when they hit 1 and 6 unique bettors --- functions/src/create-notification.ts | 101 ++-- .../email-templates/new-unique-bettor.html | 397 ++++++++++++++ .../email-templates/new-unique-bettors.html | 501 ++++++++++++++++++ functions/src/emails.ts | 61 +++ functions/src/on-create-bet.ts | 6 +- web/components/notification-settings.tsx | 7 +- 6 files changed, 1039 insertions(+), 34 deletions(-) create mode 100644 functions/src/email-templates/new-unique-bettor.html create mode 100644 functions/src/email-templates/new-unique-bettors.html diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 84edf715..e2959dda 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -8,7 +8,7 @@ import { User } from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' -import { uniq } from 'lodash' +import { groupBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' @@ -23,6 +23,7 @@ import { sendNewAnswerEmail, sendNewCommentEmail, sendNewFollowedMarketEmail, + sendNewUniqueBettorsEmail, } from './emails' import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() @@ -774,44 +775,84 @@ export const createUniqueBettorBonusNotification = async ( txnId: string, contract: Contract, amount: number, + uniqueBettorIds: string[], idempotencyKey: string ) => { - console.log('createUniqueBettorBonusNotification') const privateUser = await getPrivateUser(contractCreatorId) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( privateUser, 'unique_bettors_on_your_contract' ) - if (!sendToBrowser) return - - const notificationRef = firestore - .collection(`/users/${contractCreatorId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId: contractCreatorId, - reason: 'unique_bettors_on_your_contract', - createdTime: Date.now(), - isSeen: false, - sourceId: txnId, - sourceType: 'bonus', - sourceUpdateType: 'created', - sourceUserName: bettor.name, - sourceUserUsername: bettor.username, - sourceUserAvatarUrl: bettor.avatarUrl, - sourceText: amount.toString(), - sourceSlug: contract.slug, - sourceTitle: contract.question, - // Perhaps not necessary, but just in case - sourceContractSlug: contract.slug, - sourceContractId: contract.id, - sourceContractTitle: contract.question, - sourceContractCreatorUsername: contract.creatorUsername, + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) } - return await notificationRef.set(removeUndefinedProps(notification)) - // TODO send email notification + if (!sendToEmail) return + const uniqueBettorsExcludingCreator = uniqueBettorIds.filter( + (id) => id !== contractCreatorId + ) + // only send on 1st and 6th bettor + if ( + uniqueBettorsExcludingCreator.length !== 1 && + uniqueBettorsExcludingCreator.length !== 6 + ) + return + const totalNewBettorsToReport = + uniqueBettorsExcludingCreator.length === 1 ? 1 : 5 + + const mostRecentUniqueBettors = await getValues( + firestore + .collection('users') + .where( + 'id', + 'in', + uniqueBettorsExcludingCreator.slice( + uniqueBettorsExcludingCreator.length - totalNewBettorsToReport, + uniqueBettorsExcludingCreator.length + ) + ) + ) + + const bets = await getValues( + firestore.collection('contracts').doc(contract.id).collection('bets') + ) + // group bets by bettors + const bettorsToTheirBets = groupBy(bets, (bet) => bet.userId) + await sendNewUniqueBettorsEmail( + 'unique_bettors_on_your_contract', + contractCreatorId, + privateUser, + contract, + uniqueBettorsExcludingCreator.length, + mostRecentUniqueBettors, + bettorsToTheirBets, + Math.round(amount * totalNewBettorsToReport) + ) } export const createNewContractNotification = async ( diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html new file mode 100644 index 00000000..30da8b99 --- /dev/null +++ b/functions/src/email-templates/new-unique-bettor.html @@ -0,0 +1,397 @@ + + + + + + + New unique predictors on your market + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ + Manifold Markets + +
+
+

+ Hi {{name}},

+
+
+
+

+ Your market {{marketTitle}} just got its first prediction from a user! +
+
+ We sent you a {{bonusString}} bonus for + creating a market that appeals to others, and we'll do so for each new predictor. +
+
+ Keep up the good work and check out your newest predictor below! +

+
+
+ + + + + + + +
+
+ + {{bettor1Name}} + {{bet1Description}} +
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html new file mode 100644 index 00000000..eb4c04e2 --- /dev/null +++ b/functions/src/email-templates/new-unique-bettors.html @@ -0,0 +1,501 @@ + + + + + + + New unique predictors on your market + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ + Manifold Markets + +
+
+

+ Hi {{name}},

+
+
+
+

+ Your market {{marketTitle}} got predictions from a total of {{totalPredictors}} users! +
+
+ We sent you a {{bonusString}} bonus for getting {{newPredictors}} new predictors, + and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market). +
+
+ Keep up the good work and check out your newest predictors below! +

+
+
+ + + + + + + + + + + + + + + +
+
+ + {{bettor1Name}} + {{bet1Description}} +
+
+
+ + {{bettor2Name}} + {{bet2Description}} +
+
+
+ + {{bettor3Name}} + {{bet3Description}} +
+
+
+ + {{bettor4Name}} + {{bet4Description}} +
+
+
+ + {{bettor5Name}} + {{bet5Description}} +
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index da6a5b41..adeb3d12 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -22,6 +22,7 @@ import { notification_reason_types, getDestinationsForUser, } from '../../common/notification' +import { Dictionary } from 'lodash' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -544,3 +545,63 @@ export const sendNewFollowedMarketEmail = async ( } ) } +export const sendNewUniqueBettorsEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract, + totalPredictors: number, + newPredictors: User[], + userBets: Dictionary<[Bet, ...Bet[]]>, + bonusAmount: number +) => { + 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 + // make the emails stack for the same contract + const subject = `You made a popular market! ${ + contract.question.length > 50 + ? contract.question.slice(0, 50) + '...' + : contract.question + } just got ${ + newPredictors.length + } new predictions. Check out who's betting on it inside.` + const templateData: Record = { + name: firstName, + creatorName, + totalPredictors: totalPredictors.toString(), + bonusString: formatMoney(bonusAmount), + marketTitle: contract.question, + marketUrl: contractUrl(contract), + unsubscribeUrl, + newPredictors: newPredictors.length.toString(), + } + + newPredictors.forEach((p, i) => { + templateData[`bettor${i + 1}Name`] = p.name + if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl + const bet = userBets[p.id][0] + if (bet) { + const { amount, sale } = bet + templateData[`bet${i + 1}Description`] = `${ + sale || amount < 0 ? 'sold' : 'bought' + } ${formatMoney(Math.abs(amount))}` + } + }) + + return await sendTemplateEmail( + privateUser.email, + subject, + newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors', + templateData, + { + from: `Manifold Markets `, + } + ) +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5dbebfc3..f2c6b51a 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -28,8 +28,9 @@ import { User } from '../../common/user' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() -export const onCreateBet = functions.firestore - .document('contracts/{contractId}/bets/{betId}') +export const onCreateBet = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('contracts/{contractId}/bets/{betId}') .onCreate(async (change, context) => { const { contractId } = context.params as { contractId: string @@ -198,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( result.txn.id, contract, result.txn.amount, + newUniqueBettorIds, eventId + '-unique-bettor-bonus' ) } diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 65804991..128a89ef 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -60,11 +60,14 @@ export function NotificationSettings(props: { 'tagged_user', // missing tagged on contract description email 'contract_from_followed_user', + 'unique_bettors_on_your_contract', // TODO: add these + // one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications + // 'profit_loss_updates', - changes in markets you have shares in + // biggest winner, here are the rest of your markets + // 'referral_bonuses', - // 'unique_bettors_on_your_contract', // 'on_new_follow', - // 'profit_loss_updates', // 'tips_on_your_markets', // 'tips_on_your_comments', // maybe the following? From 34bad35cb88cf186df403d031c106c2f9f258e2a Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 16:19:52 -0600 Subject: [PATCH 07/37] Betting=>predicting --- functions/src/emails.ts | 2 +- web/components/contract/contract-details.tsx | 2 +- web/components/notification-settings.tsx | 2 +- web/components/profile/betting-streak-modal.tsx | 7 ++++--- web/pages/create.tsx | 4 ++-- web/pages/notifications.tsx | 10 ++++++---- web/pages/tournaments/index.tsx | 2 +- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index adeb3d12..e9ef9630 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -571,7 +571,7 @@ export const sendNewUniqueBettorsEmail = async ( : contract.question } just got ${ newPredictors.length - } new predictions. Check out who's betting on it inside.` + } new predictions. Check out who's predicting on it inside.` const templateData: Record = { name: firstName, creatorName, diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index c383d349..e28ab41a 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: { {volumeTranslation} diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 128a89ef..c45510ac 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -134,7 +134,7 @@ export function NotificationSettings(props: { const bonuses: sectionData = { label: 'Bonuses', subscriptionTypeToDescription: { - betting_streaks: 'Betting streak bonuses', + betting_streaks: 'Prediction streak bonuses', referral_bonuses: 'Referral bonuses from referring users', unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', }, diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 694a0193..a137833c 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -16,13 +16,14 @@ export function BettingStreakModal(props: { 🔥 - Daily betting streaks + Daily prediction streaks • What are they? You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day - of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)} - . The more days you bet in a row, the more you earn! + of consecutive predicting up to{' '} + {formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict + in a row, the more you earn! • Where can I check my streak? diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 5fb9549e..f5d1c605 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -426,7 +426,7 @@ export function NewContract(props: {
Cost {!deservesFreeMarket ? ( diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7ebc473b..1e8fbb4d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -428,7 +428,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } new traders on` + } new predictors on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` @@ -436,7 +436,7 @@ function IncomeNotificationItem(props: { if (sourceText && +sourceText === 50) reasonText = '(max) for your' else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { - reasonText = `of your invested bets returned as a` + reasonText = `of your invested predictions returned as a` // TODO: support just 'like' notification without a tip } else if (sourceType === 'tip_and_like' && sourceText) { reasonText = !simple ? `liked` : `in likes on` @@ -448,7 +448,9 @@ function IncomeNotificationItem(props: { : user?.currentBettingStreak ?? 0 const bettingStreakText = sourceType === 'betting_streak_bonus' && - (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak') + (sourceText + ? `🔥 ${streakInDays} day Prediction Streak` + : 'Prediction Streak') return ( <> @@ -546,7 +548,7 @@ function IncomeNotificationItem(props: { {(isTip || isUniqueBettorBonus) && ( )} diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 27c51c15..e81c239f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) { {sections.map( From c9d323c83ff4249d11d556b71e199fae32ce5376 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 13 Sep 2022 17:47:29 -0500 Subject: [PATCH 08/37] Small updates to experimental/home (#874) * Factor out section header * Remove daily balance change * Remove dead code * Layout, add streak * Fix visibility observer to work on server * Tweak * Formatting --- web/components/arrange-home.tsx | 2 +- web/components/bets-list.tsx | 5 +- web/components/visibility-observer.tsx | 12 ++-- web/hooks/use-contracts.ts | 37 +--------- web/pages/experimental/home/edit.tsx | 8 ++- web/pages/experimental/home/index.tsx | 99 ++++++++++++-------------- 6 files changed, 64 insertions(+), 99 deletions(-) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 646d30fe..6be187f8 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => { if (!isArray(sections)) sections = [] const items = [ - { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, { label: 'New for you', id: 'newest' }, + { label: 'Daily movers', id: 'daily-movers' }, ...groups.map((g) => ({ label: g.name, id: g.id, diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index ab232927..9c76174b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -754,7 +754,10 @@ function SellButton(props: { ) } -function ProfitBadge(props: { profitPercent: number; className?: string }) { +export function ProfitBadge(props: { + profitPercent: number + className?: string +}) { const { profitPercent, className } = props if (!profitPercent) return null const colors = diff --git a/web/components/visibility-observer.tsx b/web/components/visibility-observer.tsx index aea2e41d..288d8f0e 100644 --- a/web/components/visibility-observer.tsx +++ b/web/components/visibility-observer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useEvent } from '../hooks/use-event' export function VisibilityObserver(props: { @@ -8,18 +8,16 @@ export function VisibilityObserver(props: { const { className } = props const [elem, setElem] = useState(null) const onVisibilityUpdated = useEvent(props.onVisibilityUpdated) - const observer = useRef( - new IntersectionObserver(([entry]) => { - onVisibilityUpdated(entry.isIntersecting) - }, {}) - ).current useEffect(() => { if (elem) { + const observer = new IntersectionObserver(([entry]) => { + onVisibilityUpdated(entry.isIntersecting) + }, {}) observer.observe(elem) return () => observer.unobserve(elem) } - }, [elem, observer]) + }, [elem, onVisibilityUpdated]) return
} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 2f3bea7b..1ea2f232 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,10 +1,8 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore' -import { isEqual } from 'lodash' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { Contract, listenForActiveContracts, - listenForContract, listenForContracts, listenForHotContracts, listenForInactiveContracts, @@ -62,39 +60,6 @@ export const useHotContracts = () => { return hotContracts } -export const useUpdatedContracts = (contracts: Contract[] | undefined) => { - const [__, triggerUpdate] = useState(0) - const contractDict = useRef<{ [id: string]: Contract }>({}) - - useEffect(() => { - if (contracts === undefined) return - - contractDict.current = Object.fromEntries(contracts.map((c) => [c.id, c])) - - const disposes = contracts.map((contract) => { - const { id } = contract - - return listenForContract(id, (contract) => { - const curr = contractDict.current[id] - if (!isEqual(curr, contract)) { - contractDict.current[id] = contract as Contract - triggerUpdate((n) => n + 1) - } - }) - }) - - triggerUpdate((n) => n + 1) - - return () => { - disposes.forEach((dispose) => dispose()) - } - }, [!!contracts]) - - return contracts && Object.keys(contractDict.current).length > 0 - ? contracts.map((c) => contractDict.current[c.id]) - : undefined -} - export const usePrefetchUserBetContracts = (userId: string) => { const queryClient = useQueryClient() return queryClient.prefetchQuery( diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index 2ed9d2dd..8c242a34 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -28,7 +28,7 @@ export default function Home() { - + <Title text="Customize your home page" /> <DoneButton /> </Row> @@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) { return ( <SiteLink href="/experimental/home"> - <Button size="lg" color="blue" className={clsx(className, 'flex')}> + <Button + size="lg" + color="blue" + className={clsx(className, 'flex whitespace-nowrap')} + > Done </Button> </SiteLink> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 08f502b6..f5734918 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import Router from 'next/router' import { - PencilIcon, + AdjustmentsIcon, PlusSmIcon, ArrowSmRightIcon, } from '@heroicons/react/solid' @@ -26,11 +26,12 @@ import { Row } from 'web/components/layout/row' import { ProbChangeTable } from 'web/components/contract/prob-change-table' 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' +import { ProfitBadge } from 'web/components/bets-list' +import { calculatePortfolioProfit } from 'common/calculate-metrics' -const Home = () => { +export default function Home() { const user = useUser() useTracking('view home') @@ -44,14 +45,14 @@ const Home = () => { return ( <Page> <Col className="pm:mx-10 gap-4 px-4 pb-12"> - <Row className={'w-full items-center justify-between'}> - <Title className="!mb-0" text="Home" /> - - <EditButton /> + <Row className={'mt-4 w-full items-start justify-between'}> + <Row className="items-end gap-4"> + <Title className="!mb-1 !mt-0" text="Home" /> + <EditButton /> + </Row> + <DailyProfitAndBalance className="" user={user} /> </Row> - <DailyProfitAndBalance userId={user?.id} /> - {sections.map((item) => { const { id } = item if (id === 'daily-movers') { @@ -97,17 +98,10 @@ function SearchSection(props: { followed?: boolean }) { const { label, user, sort, yourBets, followed } = props - const href = `/home?s=${sort}` return ( <Col> - <SiteLink className="mb-2 text-xl" href={href}> - {label}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> + <SectionHeader label={label} href={`/home?s=${sort}`} /> <ContractSearch user={user} defaultSort={sort} @@ -134,13 +128,7 @@ function GroupSection(props: { return ( <Col> - <SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}> - {group.name}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> + <SectionHeader label={group.name} href={groupPath(group.slug)} /> <ContractSearch user={user} defaultSort={'score'} @@ -159,15 +147,25 @@ function DailyMoversSection(props: { userId: string | null | undefined }) { return ( <Col className="gap-2"> - <SiteLink className="text-xl" href={'/daily-movers'}> - Daily movers{' '} + <SectionHeader label="Daily movers" href="daily-movers" /> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + +function SectionHeader(props: { label: string; href: string }) { + const { label, href } = props + + return ( + <Row className="mb-3 items-center justify-between"> + <SiteLink className="text-xl" href={href}> + {label}{' '} <ArrowSmRightIcon className="mb-0.5 inline h-6 w-6 text-gray-500" aria-hidden="true" /> </SiteLink> - <ProbChangeTable changes={changes} /> - </Col> + </Row> ) } @@ -176,45 +174,42 @@ function EditButton(props: { className?: string }) { return ( <SiteLink href="/experimental/home/edit"> - <Button size="lg" color="gray-white" className={clsx(className, 'flex')}> - <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '} - Edit + <Button size="sm" color="gray-white" className={clsx(className, 'flex')}> + <AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" /> </Button> </SiteLink> ) } function DailyProfitAndBalance(props: { - userId: string | null | undefined + user: User | null | undefined className?: string }) { - const { userId, className } = props - const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? [] + const { user, className } = props + const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] const [first, last] = [metrics[0], metrics[metrics.length - 1]] if (first === undefined || last === undefined) return null const profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) - - const balanceChange = last.balance - first.balance + const profitPercent = profit / first.investmentValue return ( - <div className={clsx(className, 'text-lg')}> - <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> - {profit >= 0 && '+'} - {formatMoney(profit)} - </span>{' '} - profit and{' '} - <span - className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} - > - {balanceChange >= 0 && '+'} - {formatMoney(balanceChange)} - </span>{' '} - balance today - </div> + <Row className={'gap-4'}> + <Col> + <div className="text-gray-500">Daily profit</div> + <Row className={clsx(className, 'items-center text-lg')}> + <span>{formatMoney(profit)}</span>{' '} + <ProfitBadge profitPercent={profitPercent * 100} /> + </Row> + </Col> + <Col> + <div className="text-gray-500">Streak</div> + <Row className={clsx(className, 'items-center text-lg')}> + <span>🔥 {user?.currentBettingStreak ?? 0}</span> + </Row> + </Col> + </Row> ) } - -export default Home From df3d7b591dfa234b1102ddf0cde936a7c4d35186 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 17:00:34 -0600 Subject: [PATCH 09/37] Componentize notification line setting, don't use useEffect --- web/components/notification-settings.tsx | 143 +++++++++++++---------- web/pages/notifications.tsx | 1 + 2 files changed, 85 insertions(+), 59 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c45510ac..61e3b9d9 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -1,11 +1,10 @@ -import { usePrivateUser } from 'web/hooks/use-user' -import React, { ReactNode, useEffect, useState } from 'react' -import { LoadingIndicator } from 'web/components/loading-indicator' +import React, { memo, ReactNode, useEffect, useState } from 'react' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { notification_subscription_types, notification_destination_types, + PrivateUser, } from 'common/user' import { updatePrivateUser } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' @@ -23,21 +22,22 @@ import { 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' +import { uniq } from 'lodash' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' export function NotificationSettings(props: { navigateToSection: string | undefined + privateUser: PrivateUser }) { - const { navigateToSection } = props - const privateUser = usePrivateUser() + const { navigateToSection, privateUser } = props 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', @@ -165,32 +165,29 @@ export function NotificationSettings(props: { }, } - const NotificationSettingLine = ( - description: string, - key: keyof notification_subscription_types, - value: notification_destination_types[] - ) => { - const previousInAppValue = value.includes('browser') - const previousEmailValue = value.includes('email') + function NotificationSettingLine(props: { + description: string + subscriptionTypeKey: keyof notification_subscription_types + destinations: notification_destination_types[] + }) { + const { description, subscriptionTypeKey, destinations } = props + const previousInAppValue = destinations.includes('browser') + const previousEmailValue = destinations.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 + const highlight = navigateToSection === subscriptionTypeKey - useEffect(() => { - if ( - inAppEnabled !== previousInAppValue || - emailEnabled !== previousEmailValue - ) { - toast.promise( + const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => { + toast + .promise( updatePrivateUser(privateUser.id, { notificationSubscriptionTypes: { ...privateUser.notificationSubscriptionTypes, - [key]: filterDefined([ - inAppEnabled ? 'browser' : undefined, - emailEnabled ? 'email' : undefined, - ]), + [subscriptionTypeKey]: destinations.includes(setting) + ? destinations.filter((d) => d !== setting) + : uniq([...destinations, setting]), }, }), { @@ -199,14 +196,14 @@ export function NotificationSettings(props: { error: 'Error changing notification settings. Try again?', } ) - } - }, [ - inAppEnabled, - emailEnabled, - previousInAppValue, - previousEmailValue, - key, - ]) + .then(() => { + if (setting === 'browser') { + setInAppEnabled(newValue) + } else { + setEmailEnabled(newValue) + } + }) + } return ( <Row @@ -220,17 +217,17 @@ export function NotificationSettings(props: { <span>{description}</span> </Row> <Row className={'gap-4'}> - {!browserDisabled.includes(key) && ( + {!browserDisabled.includes(subscriptionTypeKey) && ( <SwitchSetting checked={inAppEnabled} - onChange={setInAppEnabled} + onChange={(newVal) => changeSetting('browser', newVal)} label={'Web'} /> )} - {emailsEnabled.includes(key) && ( + {emailsEnabled.includes(subscriptionTypeKey) && ( <SwitchSetting checked={emailEnabled} - onChange={setEmailEnabled} + onChange={(newVal) => changeSetting('email', newVal)} label={'Email'} /> )} @@ -246,12 +243,22 @@ export function NotificationSettings(props: { return privateUser.notificationSubscriptionTypes[key] ?? [] } - const Section = (icon: ReactNode, data: sectionData) => { + const Section = memo(function Section(props: { + icon: ReactNode + data: sectionData + }) { + const { icon, data } = props const { label, subscriptionTypeToDescription } = data const expand = navigateToSection && Object.keys(subscriptionTypeToDescription).includes(navigateToSection) - const [expanded, setExpanded] = useState(expand) + + // Not sure how to prevent re-render (and collapse of an open section) + // due to a private user settings change. Just going to persist expanded state here + const [expanded, setExpanded] = usePersistentState(expand ?? false, { + key: 'NotificationsSettingsSection-' + label, + store: storageStore(safeLocalStorage()), + }) // Not working as the default value for expanded, so using a useEffect useEffect(() => { @@ -278,19 +285,19 @@ export function NotificationSettings(props: { )} </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( + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + <NotificationSettingLine + subscriptionTypeKey={key as keyof notification_subscription_types} + destinations={getUsersSavedPreference( key as keyof notification_subscription_types - ) - ) - )} + )} + description={value} + /> + ))} </Col> </Col> ) - } + }) return ( <div className={'p-2'}> @@ -302,20 +309,38 @@ export function NotificationSettings(props: { 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)} + <Section icon={<ChatIcon className={'h-6 w-6'} />} data={comments} /> + <Section + icon={<TrendingUpIcon className={'h-6 w-6'} />} + data={updates} + /> + <Section + icon={<LightBulbIcon className={'h-6 w-6'} />} + data={answers} + /> + <Section icon={<UserIcon className={'h-6 w-6'} />} data={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)} + <Section + icon={<CurrencyDollarIcon className={'h-6 w-6'} />} + data={bonuses} + /> + <Section + icon={<CashIcon className={'h-6 w-6'} />} + data={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)} + <Section + icon={<UsersIcon className={'h-6 w-6'} />} + data={userInteractions} + /> + <Section + icon={<InboxInIcon className={'h-6 w-6'} />} + data={generalOther} + /> <WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} /> </Col> </div> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 1e8fbb4d..fcac8601 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -112,6 +112,7 @@ export default function Notifications() { content: ( <NotificationSettings navigateToSection={navigateToSection} + privateUser={privateUser} /> ), }, From 74335f2b0171ddb21acd8c9f1f46a65754b3aaa2 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 13 Sep 2022 16:16:07 -0700 Subject: [PATCH 10/37] Adjust market modal styles (#875) * Refactor add market modals into one component * Adjust style: stickier search, scroll auto --- web/components/contract-search.tsx | 2 +- web/components/contract-select-modal.tsx | 102 ++++++++++++++++++++++ web/components/editor/market-modal.tsx | 88 +++---------------- web/pages/group/[...slugs]/index.tsx | 106 +++++------------------ 4 files changed, 137 insertions(+), 161 deletions(-) create mode 100644 web/components/contract-select-modal.tsx diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index e4b7f9cf..5bd69057 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -200,7 +200,7 @@ export function ContractSearch(props: { } return ( - <Col className="h-full"> + <Col> <ContractSearchControls className={headerClassName} defaultSort={defaultSort} diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx new file mode 100644 index 00000000..9e23264a --- /dev/null +++ b/web/components/contract-select-modal.tsx @@ -0,0 +1,102 @@ +import { Contract } from 'common/contract' +import { useState } from 'react' +import { Button } from './button' +import { ContractSearch } from './contract-search' +import { Col } from './layout/col' +import { Modal } from './layout/modal' +import { Row } from './layout/row' +import { LoadingIndicator } from './loading-indicator' + +export function SelectMarketsModal(props: { + title: string + description?: React.ReactNode + open: boolean + setOpen: (open: boolean) => void + submitLabel: (length: number) => string + onSubmit: (contracts: Contract[]) => void | Promise<void> + contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]> +}) { + const { + title, + description, + open, + setOpen, + submitLabel, + onSubmit, + contractSearchOptions, + } = props + + const [contracts, setContracts] = useState<Contract[]>([]) + const [loading, setLoading] = useState(false) + + async function addContract(contract: Contract) { + if (contracts.map((c) => c.id).includes(contract.id)) { + setContracts(contracts.filter((c) => c.id !== contract.id)) + } else setContracts([...contracts, contract]) + } + + async function onFinish() { + setLoading(true) + await onSubmit(contracts) + setLoading(false) + setOpen(false) + setContracts([]) + } + + return ( + <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> + <Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> + <div className="p-8 pb-0"> + <Row> + <div className={'text-xl text-indigo-700'}>{title}</div> + + {!loading && ( + <Row className="grow justify-end gap-4"> + {contracts.length > 0 && ( + <Button onClick={onFinish} color="indigo"> + {submitLabel(contracts.length)} + </Button> + )} + <Button + onClick={() => { + if (contracts.length > 0) { + setContracts([]) + } else { + setOpen(false) + } + }} + color="gray" + > + {contracts.length > 0 ? 'Reset' : 'Cancel'} + </Button> + </Row> + )} + </Row> + {description} + </div> + + {loading && ( + <div className="w-full justify-center"> + <LoadingIndicator /> + </div> + )} + + <div className="overflow-y-auto sm:px-8"> + <ContractSearch + hideOrderSelector + onContractClick={addContract} + cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} + highlightOptions={{ + contractIds: contracts.map((c) => c.id), + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + additionalFilter={{}} /* hide pills */ + headerClassName="bg-white" + {...contractSearchOptions} + /> + </div> + </Col> + </Modal> + ) +} diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 31c437b1..1e2c1482 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -1,12 +1,6 @@ import { Editor } from '@tiptap/react' import { Contract } from 'common/contract' -import { useState } from 'react' -import { Button } from '../button' -import { ContractSearch } from '../contract-search' -import { Col } from '../layout/col' -import { Modal } from '../layout/modal' -import { Row } from '../layout/row' -import { LoadingIndicator } from '../loading-indicator' +import { SelectMarketsModal } from '../contract-select-modal' import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' @@ -17,83 +11,23 @@ export function MarketModal(props: { }) { const { editor, open, setOpen } = props - const [contracts, setContracts] = useState<Contract[]>([]) - const [loading, setLoading] = useState(false) - - async function addContract(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - setLoading(true) + function onSubmit(contracts: Contract[]) { if (contracts.length == 1) { insertContent(editor, embedContractCode(contracts[0])) } else if (contracts.length > 1) { insertContent(editor, embedContractGridCode(contracts)) } - setLoading(false) - setOpen(false) - setContracts([]) } return ( - <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> - <Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> - <Row className="p-8 pb-0"> - <div className={'text-xl text-indigo-700'}>Embed a market</div> - - {!loading && ( - <Row className="grow justify-end gap-4"> - {contracts.length == 1 && ( - <Button onClick={doneAddingContracts} color={'indigo'}> - Embed 1 question - </Button> - )} - {contracts.length > 1 && ( - <Button onClick={doneAddingContracts} color={'indigo'}> - Embed grid of {contracts.length} question - {contracts.length > 1 && 's'} - </Button> - )} - <Button - onClick={() => { - if (contracts.length > 0) { - setContracts([]) - } else { - setOpen(false) - } - }} - color="gray" - > - {contracts.length > 0 ? 'Reset' : 'Cancel'} - </Button> - </Row> - )} - </Row> - - {loading && ( - <div className="w-full justify-center"> - <LoadingIndicator /> - </div> - )} - - <div className="overflow-y-scroll sm:px-8"> - <ContractSearch - hideOrderSelector - onContractClick={addContract} - cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - highlightOptions={{ - contractIds: contracts.map((c) => c.id), - highlightClassName: - '!bg-indigo-100 outline outline-2 outline-indigo-300', - }} - additionalFilter={{}} /* hide pills */ - headerClassName="bg-white" - /> - </div> - </Col> - </Modal> + <SelectMarketsModal + title="Embed markets" + open={open} + setOpen={setOpen} + submitLabel={(len) => + len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions` + } + onSubmit={onSubmit} + /> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f124e225..f1521b42 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -31,8 +31,6 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -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 { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' @@ -51,6 +49,7 @@ import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' +import { SelectMarketsModal } from 'web/components/contract-select-modal' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -401,27 +400,12 @@ function GroupLeaderboard(props: { function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState<Contract[]>([]) - const [loading, setLoading] = useState(false) const groupContractIds = useGroupContractIds(group.id) - async function addContractToCurrentGroup(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - Promise.all( - contracts.map(async (contract) => { - setLoading(true) - await addContractToGroup(group, contract, user.id) - }) - ).then(() => { - setLoading(false) - setOpen(false) - setContracts([]) - }) + async function onSubmit(contracts: Contract[]) { + await Promise.all( + contracts.map((contract) => addContractToGroup(group, contract, user.id)) + ) } return ( @@ -437,71 +421,27 @@ function AddContractButton(props: { group: Group; user: User }) { </Button> </div> - <Modal + <SelectMarketsModal open={open} setOpen={setOpen} - className={'max-w-4xl sm:p-0'} - size={'xl'} - > - <Col - className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'} - > - <Col className="p-8 pb-0"> - <div className={'text-xl text-indigo-700'}>Add markets</div> - - <div className={'text-md my-4 text-gray-600'}> - Add pre-existing markets to this group, or{' '} - <Link href={`/create?groupId=${group.id}`}> - <span className="cursor-pointer font-semibold underline"> - create a new one - </span> - </Link> - . - </div> - - {contracts.length > 0 && ( - <Col className={'w-full '}> - {!loading ? ( - <Row className={'justify-end gap-4'}> - <Button onClick={doneAddingContracts} color={'indigo'}> - Add {contracts.length} question - {contracts.length > 1 && 's'} - </Button> - <Button - onClick={() => { - setContracts([]) - }} - color={'gray'} - > - Cancel - </Button> - </Row> - ) : ( - <Row className={'justify-center'}> - <LoadingIndicator /> - </Row> - )} - </Col> - )} - </Col> - - <div className={'overflow-y-scroll sm:px-8'}> - <ContractSearch - user={user} - hideOrderSelector={true} - onContractClick={addContractToCurrentGroup} - cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - additionalFilter={{ - excludeContractIds: groupContractIds, - }} - highlightOptions={{ - contractIds: contracts.map((c) => c.id), - highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', - }} - /> + title="Add markets" + description={ + <div className={'text-md my-4 text-gray-600'}> + Add pre-existing markets to this group, or{' '} + <Link href={`/create?groupId=${group.id}`}> + <span className="cursor-pointer font-semibold underline"> + create a new one + </span> + </Link> + . </div> - </Col> - </Modal> + } + submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`} + onSubmit={onSubmit} + contractSearchOptions={{ + additionalFilter: { excludeContractIds: groupContractIds }, + }} + /> </> ) } From f6feacfbc9bf69c98345daa7df9c1566db9b8b8d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 17:18:16 -0600 Subject: [PATCH 11/37] Fix lint and persistent storage key --- web/components/notification-settings.tsx | 26 +++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 61e3b9d9..83ebf894 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -81,14 +81,14 @@ export function NotificationSettings(props: { 'thank_you_for_purchases', ] - type sectionData = { + type SectionData = { label: string subscriptionTypeToDescription: { [key in keyof Partial<notification_subscription_types>]: string } } - const comments: sectionData = { + const comments: SectionData = { label: 'New Comments', subscriptionTypeToDescription: { all_comments_on_watched_markets: 'All new comments', @@ -102,7 +102,7 @@ export function NotificationSettings(props: { }, } - const answers: sectionData = { + const answers: SectionData = { label: 'New Answers', subscriptionTypeToDescription: { all_answers_on_watched_markets: 'All new answers', @@ -111,7 +111,7 @@ export function NotificationSettings(props: { // answers_by_market_creator_on_watched_markets: 'By market creator', }, } - const updates: sectionData = { + const updates: SectionData = { label: 'Updates & Resolutions', subscriptionTypeToDescription: { market_updates_on_watched_markets: 'All creator updates', @@ -121,7 +121,7 @@ export function NotificationSettings(props: { // probability_updates_on_watched_markets: 'Probability updates', }, } - const yourMarkets: sectionData = { + const yourMarkets: SectionData = { label: 'Markets You Created', subscriptionTypeToDescription: { your_contract_closed: 'Your market has closed (and needs resolution)', @@ -131,7 +131,7 @@ export function NotificationSettings(props: { tips_on_your_markets: 'Likes on your markets', }, } - const bonuses: sectionData = { + const bonuses: SectionData = { label: 'Bonuses', subscriptionTypeToDescription: { betting_streaks: 'Prediction streak bonuses', @@ -139,7 +139,7 @@ export function NotificationSettings(props: { unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', }, } - const otherBalances: sectionData = { + const otherBalances: SectionData = { label: 'Other', subscriptionTypeToDescription: { loan_income: 'Automatic loans from your profitable bets', @@ -147,7 +147,7 @@ export function NotificationSettings(props: { tips_on_your_comments: 'Tips on your comments', }, } - const userInteractions: sectionData = { + const userInteractions: SectionData = { label: 'Users', subscriptionTypeToDescription: { tagged_user: 'A user tagged you', @@ -155,7 +155,7 @@ export function NotificationSettings(props: { contract_from_followed_user: 'New markets created by users you follow', }, } - const generalOther: sectionData = { + const generalOther: SectionData = { label: 'Other', subscriptionTypeToDescription: { trending_markets: 'Weekly interesting markets', @@ -245,7 +245,7 @@ export function NotificationSettings(props: { const Section = memo(function Section(props: { icon: ReactNode - data: sectionData + data: SectionData }) { const { icon, data } = props const { label, subscriptionTypeToDescription } = data @@ -256,14 +256,16 @@ export function NotificationSettings(props: { // Not sure how to prevent re-render (and collapse of an open section) // due to a private user settings change. Just going to persist expanded state here const [expanded, setExpanded] = usePersistentState(expand ?? false, { - key: 'NotificationsSettingsSection-' + label, + key: + 'NotificationsSettingsSection-' + + Object.keys(subscriptionTypeToDescription).join('-'), store: storageStore(safeLocalStorage()), }) // Not working as the default value for expanded, so using a useEffect useEffect(() => { if (expand) setExpanded(true) - }, [expand]) + }, [expand, setExpanded]) return ( <Col className={clsx('ml-2 gap-2')}> From 58ef43a8ecca396ebf9e4a57354eee678eb780f0 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 13 Sep 2022 21:11:53 -0500 Subject: [PATCH 12/37] intro panel: use gradient image --- web/components/market-intro-panel.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index ef4d28a2..6b326fc6 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -9,10 +9,11 @@ export function MarketIntroPanel() { <div className="text-xl">Play-money predictions</div> <Image - height={150} - width={150} - className="self-center" - src="/flappy-logo.gif" + height={125} + width={125} + className="my-4 self-center" + src="/welcome/manipurple.png" + alt="Manifold Markets gradient logo" /> <div className="mb-4 text-sm"> @@ -22,5 +23,5 @@ export function MarketIntroPanel() { <BetSignUpPrompt /> </Col> - ) + } From be851b83829f1f2bfc267bee3d9496cc2298fef3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 13 Sep 2022 21:23:36 -0500 Subject: [PATCH 13/37] fix typo --- web/components/market-intro-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index 6b326fc6..11bdf1df 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -23,5 +23,5 @@ export function MarketIntroPanel() { <BetSignUpPrompt /> </Col> - + ) } From e7d8cfe7e0a8fd68585746746b0eb53b42d6f6b1 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Wed, 14 Sep 2022 00:26:47 -0500 Subject: [PATCH 14/37] House liquidity (#876) * add house liquidity for unique bettors * hide notifications from house liquidity * up bonus liquidity to M$20 --- common/add-liquidity.ts | 5 +- common/antes.ts | 2 + functions/src/add-liquidity.ts | 47 ++++++++++++++++++- functions/src/on-create-bet.ts | 9 +++- .../src/on-create-liquidity-provision.ts | 7 ++- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/common/add-liquidity.ts b/common/add-liquidity.ts index 254b8936..9271bbbf 100644 --- a/common/add-liquidity.ts +++ b/common/add-liquidity.ts @@ -1,10 +1,9 @@ import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' import { CPMMContract } from './contract' import { LiquidityProvision } from './liquidity-provision' -import { User } from './user' export const getNewLiquidityProvision = ( - user: User, + userId: string, amount: number, contract: CPMMContract, newLiquidityProvisionId: string @@ -18,7 +17,7 @@ export const getNewLiquidityProvision = ( const newLiquidityProvision: LiquidityProvision = { id: newLiquidityProvisionId, - userId: user.id, + userId: userId, contractId: contract.id, amount, pool: newPool, diff --git a/common/antes.ts b/common/antes.ts index d4e624b1..ba7c95e8 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -16,6 +16,8 @@ import { Answer } from './answer' export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id +export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20 + export function getCpmmInitialLiquidity( providerId: string, contract: CPMMBinaryContract, diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 6746486e..e6090111 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -1,11 +1,16 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { Contract } from '../../common/contract' +import { Contract, CPMMContract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' import { getNewLiquidityProvision } from '../../common/add-liquidity' import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' const bodySchema = z.object({ contractId: z.string(), @@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = getNewLiquidityProvision( - user, + user.id, amount, contract, newLiquidityProvisionDoc.id @@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { }) const firestore = admin.firestore() + +export const addHouseLiquidity = (contract: CPMMContract, amount: number) => { + return firestore.runTransaction(async (transaction) => { + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contract.id}/liquidity`) + .doc() + + const providerId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + providerId, + amount, + contract, + newLiquidityProvisionDoc.id + ) + + if (newP !== undefined && !isFinite(newP)) { + throw new APIError( + 500, + 'Liquidity injection rejected due to overflow error.' + ) + } + + transaction.update( + firestore.doc(`contracts/${contract.id}`), + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, + }) + ) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + }) +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index f2c6b51a..6b5f7eac 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -24,6 +24,8 @@ import { } from '../../common/antes' import { APIError } from '../../common/api' import { User } from '../../common/user' +import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' +import { addHouseLiquidity } from './add-liquidity' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -149,18 +151,23 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) - const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) + // Update contract unique bettors if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) } + if (contract.mechanism === 'cpmm-1' && isNewUniqueBettor) { + await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) + } + // No need to give a bonus for the creator's bet if (!isNewUniqueBettor || bettor.id == contract.creatorId) return diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 3a1e551f..54da7fd9 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, + UNIQUE_BETTOR_LIQUIDITY_AMOUNT, } from '../../common/antes' export const onCreateLiquidityProvision = functions.firestore @@ -17,9 +18,11 @@ export const onCreateLiquidityProvision = functions.firestore // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision if ( - (liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || + liquidity.isAnte || + ((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) && - liquidity.amount === FIXED_ANTE + (liquidity.amount === FIXED_ANTE || + liquidity.amount === UNIQUE_BETTOR_LIQUIDITY_AMOUNT)) ) return From 273b815e544bf377669324cb3bbfdd0e7ecda0b9 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 14 Sep 2022 00:51:43 -0500 Subject: [PATCH 15/37] hide house liquidity on feed --- web/components/feed/feed-liquidity.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index e2a80624..ba0cd490 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -9,13 +9,17 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' import { LiquidityProvision } from 'common/liquidity-provision' import { UserLink } from 'web/components/user-link' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/antes' export function FeedLiquidity(props: { className?: string liquidity: LiquidityProvision }) { const { liquidity } = props - const { userId, createdTime } = liquidity + const { userId, createdTime, isAnte } = liquidity const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') // eslint-disable-next-line react-hooks/rules-of-hooks @@ -24,6 +28,13 @@ export function FeedLiquidity(props: { const user = useUser() const isSelf = user?.id === userId + if ( + isAnte || + userId === HOUSE_LIQUIDITY_PROVIDER_ID || + userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID + ) + return <></> + return ( <Row className="flex w-full gap-2 pt-3"> {isSelf ? ( From 1ebb505752bd4e8ccb5cf2de69d19b2a162eee8b Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 14 Sep 2022 01:13:53 -0700 Subject: [PATCH 16/37] Fix liquidity feed display to look right (#877) --- web/components/feed/feed-liquidity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index ba0cd490..8f8faf9b 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -36,7 +36,7 @@ export function FeedLiquidity(props: { return <></> return ( - <Row className="flex w-full gap-2 pt-3"> + <Row className="items-center gap-2 pt-3"> {isSelf ? ( <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( From 7144e57c93a6254c656d8b5574d685517ec07539 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 14 Sep 2022 01:33:59 -0700 Subject: [PATCH 17/37] Denormalize user display fields onto bets (#853) * Denormalize user display fields onto bets * Make bet denormalization script fast enough to run it on prod * Make `placeBet`/`sellShares` immediately post denormalized info --- common/antes.ts | 16 ++++--- common/bet.ts | 6 +++ common/new-bet.ts | 7 ++- common/sell-bet.ts | 5 +- functions/src/change-user-info.ts | 12 +++++ functions/src/on-create-bet.ts | 6 +++ functions/src/place-bet.ts | 9 +++- .../src/scripts/denormalize-avatar-urls.ts | 48 +++++++------------ .../src/scripts/denormalize-bet-user-data.ts | 38 +++++++++++++++ .../scripts/denormalize-comment-bet-data.ts | 30 ++++++------ .../denormalize-comment-contract-data.ts | 24 ++++------ functions/src/scripts/denormalize.ts | 45 +++++++++++------ functions/src/sell-shares.ts | 3 ++ .../contract/contract-leaderboard.tsx | 5 +- web/components/feed/feed-bets.tsx | 34 +++++-------- web/components/limit-bets.tsx | 8 ++-- 16 files changed, 180 insertions(+), 116 deletions(-) create mode 100644 functions/src/scripts/denormalize-bet-user-data.ts diff --git a/common/antes.ts b/common/antes.ts index ba7c95e8..51aac20f 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -15,9 +15,13 @@ import { Answer } from './answer' export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id - export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20 +type NormalizedBet<T extends Bet = Bet> = Omit< + T, + 'userAvatarUrl' | 'userName' | 'userUsername' +> + export function getCpmmInitialLiquidity( providerId: string, contract: CPMMBinaryContract, @@ -53,7 +57,7 @@ export function getAnteBets( const { createdTime } = contract - const yesBet: Bet = { + const yesBet: NormalizedBet = { id: yesAnteId, userId: creator.id, contractId: contract.id, @@ -67,7 +71,7 @@ export function getAnteBets( fees: noFees, } - const noBet: Bet = { + const noBet: NormalizedBet = { id: noAnteId, userId: creator.id, contractId: contract.id, @@ -95,7 +99,7 @@ export function getFreeAnswerAnte( const { createdTime } = contract - const anteBet: Bet = { + const anteBet: NormalizedBet = { id: anteBetId, userId: anteBettorId, contractId: contract.id, @@ -125,7 +129,7 @@ export function getMultipleChoiceAntes( const { createdTime } = contract - const bets: Bet[] = answers.map((answer, i) => ({ + const bets: NormalizedBet[] = answers.map((answer, i) => ({ id: betDocIds[i], userId: creator.id, contractId: contract.id, @@ -175,7 +179,7 @@ export function getNumericAnte( range(0, bucketCount).map((_, i) => [i, betAnte]) ) - const anteBet: NumericBet = { + const anteBet: NormalizedBet<NumericBet> = { id: newBetId, userId: anteBettorId, contractId: contract.id, diff --git a/common/bet.ts b/common/bet.ts index 8afebcd8..ee869bb5 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -3,6 +3,12 @@ import { Fees } from './fees' export type Bet = { id: string userId: string + + // denormalized for bet lists + userAvatarUrl?: string + userUsername: string + userName: string + contractId: string createdTime: number diff --git a/common/new-bet.ts b/common/new-bet.ts index 7085a4fe..91faf640 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -31,7 +31,10 @@ import { floatingLesserEqual, } from './util/math' -export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet = Bet> = Omit< + T, + 'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' +> export type BetInfo = { newBet: CandidateBet newPool?: { [outcome: string]: number } @@ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract, + contract: FreeResponseContract | MultipleChoiceContract ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/sell-bet.ts b/common/sell-bet.ts index bc8fe596..96636ca0 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' import { sumBy } from 'lodash' -export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet> = Omit< + T, + 'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' +> export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { const { pool, totalShares, totalBets } = contract diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index ca66f1ba..53908741 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { getUser } from './utils' +import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { Comment } from '../../common/comment' import { User } from '../../common/user' @@ -68,10 +69,21 @@ export const changeUser = async ( .get() const answerUpdate: Partial<Answer> = removeUndefinedProps(update) + const betsSnap = await firestore + .collectionGroup('bets') + .where('userId', '==', user.id) + .get() + const betsUpdate: Partial<Bet> = removeUndefinedProps({ + userName: update.name, + userUsername: update.username, + userAvatarUrl: update.avatarUrl, + }) + const bulkWriter = firestore.bulkWriter() commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate)) answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate)) contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate)) + betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate)) await bulkWriter.flush() console.log('Done writing!') diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 6b5f7eac..f54d6475 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -61,6 +61,12 @@ export const onCreateBet = functions const bettor = await getUser(bet.userId) if (!bettor) return + await change.ref.update({ + userAvatarUrl: bettor.avatarUrl, + userName: bettor.name, + userUsername: bettor.username, + }) + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index d98430c1..74df7dc3 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => { } const betDoc = contractDoc.collection('bets').doc() - trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) + trans.create(betDoc, { + id: betDoc.id, + userId: user.id, + userAvatarUrl: user.avatarUrl, + userUsername: user.username, + userName: user.name, + ...newBet, + }) log('Created new bet document.') if (makers) { diff --git a/functions/src/scripts/denormalize-avatar-urls.ts b/functions/src/scripts/denormalize-avatar-urls.ts index 23b7dfc9..fd95ec8f 100644 --- a/functions/src/scripts/denormalize-avatar-urls.ts +++ b/functions/src/scripts/denormalize-avatar-urls.ts @@ -3,12 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' initAdmin() @@ -79,43 +74,36 @@ if (require.main === module) { getAnswersByUserId(transaction), ]) - const usersContracts = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, contractsByUserId.get(id) || []] - } - ) - const contractDiffs = findDiffs( - usersContracts, + const usersContracts = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, contractsByUserId.get(id) || []] as const + }) + const contractDiffs = findDiffs(usersContracts, [ 'avatarUrl', - 'creatorAvatarUrl' - ) + 'creatorAvatarUrl', + ]) console.log(`Found ${contractDiffs.length} contracts with mismatches.`) contractDiffs.forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) }) - const usersComments = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, commentsByUserId.get(id) || []] - } - ) - const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl') + const usersComments = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, commentsByUserId.get(id) || []] as const + }) + const commentDiffs = findDiffs(usersComments, [ + 'avatarUrl', + 'userAvatarUrl', + ]) console.log(`Found ${commentDiffs.length} comments with mismatches.`) commentDiffs.forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) }) - const usersAnswers = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, answersByUserId.get(id) || []] - } - ) - const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl') + const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, answersByUserId.get(id) || []] as const + }) + const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl']) console.log(`Found ${answerDiffs.length} answers with mismatches.`) answerDiffs.forEach((d) => { console.log(describeDiff(d)) diff --git a/functions/src/scripts/denormalize-bet-user-data.ts b/functions/src/scripts/denormalize-bet-user-data.ts new file mode 100644 index 00000000..3c86e140 --- /dev/null +++ b/functions/src/scripts/denormalize-bet-user-data.ts @@ -0,0 +1,38 @@ +// Filling in the user-based fields on bets. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { findDiffs, describeDiff, getDiffUpdate } from './denormalize' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +// not in a transaction for speed -- may need to be run more than once +async function denormalize() { + const users = await firestore.collection('users').get() + log(`Found ${users.size} users.`) + for (const userDoc of users.docs) { + const userBets = await firestore + .collectionGroup('bets') + .where('userId', '==', userDoc.id) + .get() + const mapping = [[userDoc, userBets.docs] as const] as const + const diffs = findDiffs( + mapping, + ['avatarUrl', 'userAvatarUrl'], + ['name', 'userName'], + ['username', 'userUsername'] + ) + log(`Found ${diffs.length} bets with mismatched user data.`) + const updates = diffs.map((d) => { + log(describeDiff(d)) + return getDiffUpdate(d) + }) + await writeAsync(firestore, updates) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts index 929626c3..a5fb8759 100644 --- a/functions/src/scripts/denormalize-comment-bet-data.ts +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -3,12 +3,7 @@ import * as admin from 'firebase-admin' import { zip } from 'lodash' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { log } from '../utils' import { Transaction } from 'firebase-admin/firestore' @@ -41,17 +36,20 @@ async function denormalize() { ) ) log(`Found ${bets.length} bets associated with comments.`) - const mapping = zip(bets, betComments) - .map(([bet, comment]): DocumentCorrespondence => { - return [bet!, [comment!]] // eslint-disable-line - }) - .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs - const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') - const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') - log(`Found ${amountDiffs.length} comments with mismatched amounts.`) - log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) - const diffs = amountDiffs.concat(outcomeDiffs) + // dev DB has some invalid bet IDs + const mapping = zip(bets, betComments) + .filter(([bet, _]) => bet!.exists) // eslint-disable-line + .map(([bet, comment]) => { + return [bet!, [comment!]] as const // eslint-disable-line + }) + + const diffs = findDiffs( + mapping, + ['amount', 'betAmount'], + ['outcome', 'betOutcome'] + ) + log(`Found ${diffs.length} comments with mismatched data.`) diffs.slice(0, 500).forEach((d) => { log(describeDiff(d)) applyDiff(trans, d) diff --git a/functions/src/scripts/denormalize-comment-contract-data.ts b/functions/src/scripts/denormalize-comment-contract-data.ts index 0358c5a1..150b833d 100644 --- a/functions/src/scripts/denormalize-comment-contract-data.ts +++ b/functions/src/scripts/denormalize-comment-contract-data.ts @@ -2,12 +2,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' initAdmin() @@ -43,16 +38,15 @@ async function denormalize() { getContractsById(transaction), getCommentsByContractId(transaction), ]) - const mapping = Object.entries(contractsById).map( - ([id, doc]): DocumentCorrespondence => { - return [doc, commentsByContractId.get(id) || []] - } + const mapping = Object.entries(contractsById).map(([id, doc]) => { + return [doc, commentsByContractId.get(id) || []] as const + }) + const diffs = findDiffs( + mapping, + ['slug', 'contractSlug'], + ['question', 'contractQuestion'] ) - const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug') - const qDiffs = findDiffs(mapping, 'question', 'contractQuestion') - console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`) - console.log(`Found ${qDiffs.length} comments with mismatched questions.`) - const diffs = slugDiffs.concat(qDiffs) + console.log(`Found ${diffs.length} comments with mismatched data.`) diffs.slice(0, 500).forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts index 20bfc458..d4feb425 100644 --- a/functions/src/scripts/denormalize.ts +++ b/functions/src/scripts/denormalize.ts @@ -2,32 +2,40 @@ // another set of documents. import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' +import { isEqual, zip } from 'lodash' +import { UpdateSpec } from '../utils' export type DocumentValue = { doc: DocumentSnapshot - field: string - val: unknown + fields: string[] + vals: unknown[] } -export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]] +export type DocumentMapping = readonly [ + DocumentSnapshot, + readonly DocumentSnapshot[] +] export type DocumentDiff = { src: DocumentValue dest: DocumentValue } +type PathPair = readonly [string, string] + export function findDiffs( - docs: DocumentCorrespondence[], - srcPath: string, - destPath: string + docs: readonly DocumentMapping[], + ...paths: PathPair[] ) { const diffs: DocumentDiff[] = [] + const srcPaths = paths.map((p) => p[0]) + const destPaths = paths.map((p) => p[1]) for (const [srcDoc, destDocs] of docs) { - const srcVal = srcDoc.get(srcPath) + const srcVals = srcPaths.map((p) => srcDoc.get(p)) for (const destDoc of destDocs) { - const destVal = destDoc.get(destPath) - if (destVal !== srcVal) { + const destVals = destPaths.map((p) => destDoc.get(p)) + if (!isEqual(srcVals, destVals)) { diffs.push({ - src: { doc: srcDoc, field: srcPath, val: srcVal }, - dest: { doc: destDoc, field: destPath, val: destVal }, + src: { doc: srcDoc, fields: srcPaths, vals: srcVals }, + dest: { doc: destDoc, fields: destPaths, vals: destVals }, }) } } @@ -37,12 +45,19 @@ export function findDiffs( export function describeDiff(diff: DocumentDiff) { function describeDocVal(x: DocumentValue): string { - return `${x.doc.ref.path}.${x.field}: ${x.val}` + return `${x.doc.ref.path}.[${x.fields.join('|')}]: [${x.vals.join('|')}]` } return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}` } -export function applyDiff(transaction: Transaction, diff: DocumentDiff) { - const { src, dest } = diff - transaction.update(dest.doc.ref, dest.field, src.val) +export function getDiffUpdate(diff: DocumentDiff) { + return { + doc: diff.dest.doc.ref, + fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), + } as UpdateSpec +} + +export function applyDiff(transaction: Transaction, diff: DocumentDiff) { + const update = getDiffUpdate(diff) + transaction.update(update.doc, update.fields) } diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 0e88a0b5..f2f475cb 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -112,6 +112,9 @@ export const sellshares = newEndpoint({}, async (req, auth) => { transaction.create(newBetDoc, { id: newBetDoc.id, userId: user.id, + userAvatarUrl: user.avatarUrl, + userUsername: user.username, + userName: user.name, ...newBet, }) transaction.update( diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 1eaf7043..54b2c79e 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -6,7 +6,6 @@ import { formatMoney } from 'common/util/format' import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' import { useState, useMemo, useEffect } from 'react' import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { useUserById } from 'web/hooks/use-user' import { listUsers, User } from 'web/lib/firebase/users' import { FeedBet } from '../feed/feed-bets' import { FeedComment } from '../feed/feed-comments' @@ -88,7 +87,7 @@ export function ContractTopTrades(props: { // Now find the betId with the highest profit const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = useUserById(betsById[topBetId]?.userId) + const topBettor = betsById[topBetId]?.userName // And also the commentId of the comment with the highest profit const topCommentId = sortBy( @@ -121,7 +120,7 @@ export function ContractTopTrades(props: { <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> <div className="mt-2 ml-2 text-sm text-gray-500"> - {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + {topBettor} made {formatMoney(profitById[topBetId] || 0)}! </div> </> )} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index cf444061..def97801 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -1,8 +1,7 @@ import dayjs from 'dayjs' import { Contract } from 'common/contract' import { Bet } from 'common/bet' -import { User } from 'common/user' -import { useUser, useUserById } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' @@ -18,29 +17,20 @@ import { UserLink } from 'web/components/user-link' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props - const { userId, createdTime } = bet - - const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') - // eslint-disable-next-line react-hooks/rules-of-hooks - const bettor = isBeforeJune2022 ? undefined : useUserById(userId) - - const user = useUser() - const isSelf = user?.id === userId + const { userAvatarUrl, userUsername, createdTime } = bet + const showUser = dayjs(createdTime).isAfter('2022-06-01') return ( <Row className="items-center gap-2 pt-3"> - {isSelf ? ( - <Avatar avatarUrl={user.avatarUrl} username={user.username} /> - ) : bettor ? ( - <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> + {showUser ? ( + <Avatar avatarUrl={userAvatarUrl} username={userUsername} /> ) : ( <EmptyAvatar className="mx-1" /> )} <BetStatusText bet={bet} contract={contract} - isSelf={isSelf} - bettor={bettor} + hideUser={!showUser} className="flex-1" /> </Row> @@ -50,13 +40,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) { export function BetStatusText(props: { contract: Contract bet: Bet - isSelf: boolean - bettor?: User + hideUser?: boolean hideOutcome?: boolean className?: string }) { - const { bet, contract, bettor, isSelf, hideOutcome, className } = props + const { bet, contract, hideUser, hideOutcome, className } = props const { outcomeType } = contract + const self = useUser() const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isFreeResponse = outcomeType === 'FREE_RESPONSE' const { amount, outcome, createdTime, challengeSlug } = bet @@ -101,10 +91,10 @@ export function BetStatusText(props: { return ( <div className={clsx('text-sm text-gray-500', className)}> - {bettor ? ( - <UserLink name={bettor.name} username={bettor.username} /> + {!hideUser ? ( + <UserLink name={bet.userName} username={bet.userUsername} /> ) : ( - <span>{isSelf ? 'You' : 'A trader'}</span> + <span>{self?.id === bet.userId ? 'You' : 'A trader'}</span> )}{' '} {bought} {money} {outOfTotalAmount} diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 466b7a9b..606bc7e0 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' import { formatMoney, formatPercent } from 'common/util/format' import { sortBy } from 'lodash' import { useState } from 'react' -import { useUser, useUserById } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { cancelBet } from 'web/lib/firebase/api' import { Avatar } from './avatar' import { Button } from './button' @@ -109,16 +109,14 @@ function LimitBet(props: { setIsCancelling(true) } - const user = useUserById(bet.userId) - return ( <tr> {!isYou && ( <td> <Avatar size={'sm'} - avatarUrl={user?.avatarUrl} - username={user?.username} + avatarUrl={bet.userAvatarUrl} + username={bet.userUsername} /> </td> )} From a2d61a1daa2b3276c8e1d60682a0161db8d54593 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Wed, 14 Sep 2022 03:52:31 -0500 Subject: [PATCH 18/37] Twitch integration (#815) * twitch account linking; profile page twitch panel; twitch landing page * fix import * twitch logo * save twitch credentials cloud function * use user id instead of bot id, add manifold api endpoint * properly add function to index * Added support for new redirect Twitch auth. * Added clean error handling in case of Twitch link fail. * remove simulator * Removed legacy non-redirect Twitch auth code. Added "add bot to channel" button in user profile and relevant data to user type. * Removed unnecessary imports. * Fixed line endings. * Allow users to modify private user twitchInfo firestore object * Local dev on savetwitchcredentials function Co-authored-by: Phil <phil.bladen@gmail.com> Co-authored-by: Marshall Polaris <marshall@pol.rs> --- common/user.ts | 5 + firestore.rules | 4 +- functions/src/index.ts | 3 + functions/src/save-twitch-credentials.ts | 22 ++++ functions/src/serve.ts | 2 + web/components/profile/twitch-panel.tsx | 133 +++++++++++++++++++++++ web/lib/api/api-key.ts | 9 ++ web/lib/twitch/link-twitch-account.ts | 41 +++++++ web/pages/api/v0/twitch/save.ts | 23 ++++ web/pages/profile.tsx | 17 ++- web/pages/twitch.tsx | 120 ++++++++++++++++++++ web/public/twitch-logo.png | Bin 0 -> 23022 bytes 12 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 functions/src/save-twitch-credentials.ts create mode 100644 web/components/profile/twitch-panel.tsx create mode 100644 web/lib/api/api-key.ts create mode 100644 web/lib/twitch/link-twitch-account.ts create mode 100644 web/pages/api/v0/twitch/save.ts create mode 100644 web/pages/twitch.tsx create mode 100644 web/public/twitch-logo.png diff --git a/common/user.ts b/common/user.ts index f8b4f8d8..5d427744 100644 --- a/common/user.ts +++ b/common/user.ts @@ -68,6 +68,11 @@ export type PrivateUser = { /** @deprecated - use notificationSubscriptionTypes */ notificationPreferences?: notification_subscribe_types notificationSubscriptionTypes: notification_subscription_types + twitchInfo?: { + twitchName: string + controlToken: string + botEnabled?: boolean + } } export type notification_destination_types = 'email' | 'browser' diff --git a/firestore.rules b/firestore.rules index d24d4097..82392787 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails', 'notificationSubscriptionTypes', 'twitchInfo']); } 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; } diff --git a/functions/src/index.ts b/functions/src/index.ts index be73b6af..adfee75e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' +import { savetwitchcredentials } from './save-twitch-credentials' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) +const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) export { healthFunction as health, @@ -119,4 +121,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, createPostFunction as createpost, + saveTwitchCredentials as savetwitchcredentials } diff --git a/functions/src/save-twitch-credentials.ts b/functions/src/save-twitch-credentials.ts new file mode 100644 index 00000000..80dc86a6 --- /dev/null +++ b/functions/src/save-twitch-credentials.ts @@ -0,0 +1,22 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { newEndpoint, validate } from './api' + +const bodySchema = z.object({ + twitchInfo: z.object({ + twitchName: z.string(), + controlToken: z.string(), + }), +}) + + +export const savetwitchcredentials = newEndpoint({}, async (req, auth) => { + const { twitchInfo } = validate(bodySchema, req.body) + const userId = auth.uid + + await firestore.doc(`private-users/${userId}`).update({ twitchInfo }) + return { success: true } +}) + +const firestore = admin.firestore() diff --git a/functions/src/serve.ts b/functions/src/serve.ts index a5291f19..6d062d40 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' +import { savetwitchcredentials } from './save-twitch-credentials' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx new file mode 100644 index 00000000..b284b242 --- /dev/null +++ b/web/components/profile/twitch-panel.tsx @@ -0,0 +1,133 @@ +import clsx from 'clsx' +import { MouseEventHandler, ReactNode, useState } from 'react' +import toast from 'react-hot-toast' + +import { LinkIcon } from '@heroicons/react/solid' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { updatePrivateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { copyToClipboard } from 'web/lib/util/copy' +import { Button, ColorType } from './../button' +import { Row } from './../layout/row' +import { LoadingIndicator } from './../loading-indicator' + +function BouncyButton(props: { + children: ReactNode + onClick?: MouseEventHandler<any> + color?: ColorType +}) { + const { children, onClick, color } = props + return ( + <Button + color={color} + size="lg" + onClick={onClick} + className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" + > + {children} + </Button> + ) +} + +export function TwitchPanel() { + const user = useUser() + const privateUser = usePrivateUser() + + const twitchInfo = privateUser?.twitchInfo + const twitchName = privateUser?.twitchInfo?.twitchName + const twitchToken = privateUser?.twitchInfo?.controlToken + const twitchBotConnected = privateUser?.twitchInfo?.botEnabled + + const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + + const copyOverlayLink = async () => { + copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`) + toast.success('Overlay link copied!', { + icon: linkIcon, + }) + } + + const copyDockLink = async () => { + copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`) + toast.success('Dock link copied!', { + icon: linkIcon, + }) + } + + const updateBotConnected = (connected: boolean) => async () => { + if (user && twitchInfo) { + twitchInfo.botEnabled = connected + await updatePrivateUser(user.id, { twitchInfo }) + } + } + + const [twitchLoading, setTwitchLoading] = useState(false) + + const createLink = async () => { + if (!user || !privateUser) return + setTwitchLoading(true) + + const promise = linkTwitchAccountRedirect(user, privateUser) + track('link twitch from profile') + await promise + + setTwitchLoading(false) + } + + return ( + <> + <div> + <label className="label">Twitch</label> + + {!twitchName ? ( + <Row> + <Button + color="indigo" + onClick={createLink} + disabled={twitchLoading} + > + Link your Twitch account + </Button> + {twitchLoading && <LoadingIndicator className="ml-4" />} + </Row> + ) : ( + <Row> + <span className="mr-4 text-gray-500">Linked Twitch account</span>{' '} + {twitchName} + </Row> + )} + </div> + + {twitchToken && ( + <div> + <div className="flex w-full"> + <div + className={clsx( + 'flex grow gap-4', + twitchToken ? '' : 'tooltip tooltip-top' + )} + data-tip="You must link your Twitch account first" + > + <BouncyButton color="blue" onClick={copyOverlayLink}> + Copy overlay link + </BouncyButton> + <BouncyButton color="indigo" onClick={copyDockLink}> + Copy dock link + </BouncyButton> + {twitchBotConnected ? ( + <BouncyButton color="red" onClick={updateBotConnected(false)}> + Remove bot from your channel + </BouncyButton> + ) : ( + <BouncyButton color="green" onClick={updateBotConnected(true)}> + Add bot to your channel + </BouncyButton> + )} + </div> + </div> + </div> + )} + </> + ) +} diff --git a/web/lib/api/api-key.ts b/web/lib/api/api-key.ts new file mode 100644 index 00000000..1a8c84c1 --- /dev/null +++ b/web/lib/api/api-key.ts @@ -0,0 +1,9 @@ +import { updatePrivateUser } from '../firebase/users' + +export const generateNewApiKey = async (userId: string) => { + const newApiKey = crypto.randomUUID() + + return await updatePrivateUser(userId, { apiKey: newApiKey }) + .then(() => newApiKey) + .catch(() => undefined) +} diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts new file mode 100644 index 00000000..36fb12b5 --- /dev/null +++ b/web/lib/twitch/link-twitch-account.ts @@ -0,0 +1,41 @@ +import { PrivateUser, User } from 'common/user' +import { generateNewApiKey } from '../api/api-key' + +const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately + +export async function initLinkTwitchAccount( + manifoldUserID: string, + manifoldUserAPIKey: string +): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { + const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, + }), + }) + const responseData = await response.json() + if (!response.ok) { + throw new Error(responseData.message) + } + const responseFetch = fetch( + `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` + ) + return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] +} + +export async function linkTwitchAccountRedirect( + user: User, + privateUser: PrivateUser +) { + const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id)) + if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key") + + const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) + + window.location.href = twitchAuthURL +} diff --git a/web/pages/api/v0/twitch/save.ts b/web/pages/api/v0/twitch/save.ts new file mode 100644 index 00000000..775817e9 --- /dev/null +++ b/web/pages/api/v0/twitch/save.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + try { + const backendRes = await fetchBackend(req, 'savetwitchcredentials') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 240fe8fa..6b70b5d2 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { User, PrivateUser } from 'common/user' -import { - getUserAndPrivateUser, - updateUser, - updatePrivateUser, -} from 'web/lib/firebase/users' +import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { generateNewApiKey } from 'web/lib/api/api-key' +import { TwitchPanel } from 'web/components/profile/twitch-panel' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -96,11 +94,8 @@ export default function ProfilePage(props: { } const updateApiKey = async (e: React.MouseEvent) => { - const newApiKey = crypto.randomUUID() - setApiKey(newApiKey) - await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { - setApiKey(privateUser.apiKey || '') - }) + const newApiKey = await generateNewApiKey(user.id) + setApiKey(newApiKey ?? '') e.preventDefault() } @@ -242,6 +237,8 @@ export default function ProfilePage(props: { </button> </div> </div> + + <TwitchPanel /> </Col> </Col> </Page> diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx new file mode 100644 index 00000000..7ca892e8 --- /dev/null +++ b/web/pages/twitch.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' + +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { SEO } from 'web/components/SEO' +import { Spacer } from 'web/components/layout/spacer' +import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { useTracking } from 'web/hooks/use-tracking' +import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { LoadingIndicator } from 'web/components/loading-indicator' +import toast from 'react-hot-toast' + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() + const twitchUser = privateUser?.twitchInfo?.twitchName + + const callback = + user && privateUser + ? () => linkTwitchAccountRedirect(user, privateUser) + : async () => { + const result = await firebaseLogin() + + const userId = result.user.uid + const { user, privateUser } = await getUserAndPrivateUser(userId) + if (!user || !privateUser) return + + await linkTwitchAccountRedirect(user, privateUser) + } + + const [isLoading, setLoading] = useState(false) + + const getStarted = async () => { + try { + setLoading(true) + + const promise = callback() + track('twitch page button click') + await promise + } catch (e) { + console.error(e) + toast.error('Failed to sign up. Please try again later.') + setLoading(false) + } + } + + return ( + <Page> + <SEO + title="Manifold Markets on Twitch" + description="Get more out of Twitch with play-money betting markets." + /> + <div className="px-4 pt-2 md:mt-0 lg:hidden"> + <ManifoldLogo /> + </div> + <Col className="items-center"> + <Col className="max-w-3xl"> + <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> + <Row className="self-center"> + <img height={200} width={200} src="/twitch-logo.png" /> + <img height={200} width={200} src="/flappy-logo.gif" /> + </Row> + <div className="m-4 max-w-[550px] self-center"> + <h1 className="text-3xl sm:text-6xl xl:text-6xl"> + <div className="font-semibold sm:mb-2"> + <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> + Bet + </span>{' '} + on your favorite streams + </div> + </h1> + <Spacer h={6} /> + <div className="mb-4 px-2 "> + Get more out of Twitch with play-money betting markets.{' '} + {!twitchUser && + 'Click the button below to link your Twitch account.'} + <br /> + </div> + </div> + + <Spacer h={6} /> + + {twitchUser ? ( + <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> + <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> + <div className="truncate text-sm font-medium text-gray-500"> + Twitch account linked + </div> + <div className="mt-1 text-2xl font-semibold text-gray-900"> + {twitchUser} + </div> + </div> + </div> + ) : isLoading ? ( + <LoadingIndicator spinnerClassName="!w-16 !h-16" /> + ) : ( + <Button + size="2xl" + color="gradient" + className="self-center" + onClick={getStarted} + > + Get started + </Button> + )} + </Col> + </Col> + </Col> + </Page> + ) +} diff --git a/web/public/twitch-logo.png b/web/public/twitch-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7f575e7a1012aeafae3b44de35d61acfbe40a554 GIT binary patch literal 23022 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 aq_+(@Pw`-Heg3;WSy@5-ZH=6H@c#gIvA;tA literal 0 HcmV?d00001 From d6b0a1edc03b558ac0365b78056c00d21ae75966 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 07:27:20 -0600 Subject: [PATCH 19/37] Betting streak reset to 7am UTC and store streak data on notif --- common/economy.ts | 2 +- common/notification.ts | 7 ++++++- functions/src/create-notification.ts | 6 ++++++ functions/src/on-create-bet.ts | 24 +++++++++++++++--------- web/pages/notifications.tsx | 13 +++++++------ 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/common/economy.ts b/common/economy.ts index c1449d4f..a412d4de 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 -export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 +export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 diff --git a/common/notification.ts b/common/notification.ts index 42dbbf35..47c55cc6 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -18,7 +18,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string - data?: string + data?: { [key: string]: any } sourceContractTitle?: string sourceContractCreatorUsername?: string @@ -157,3 +157,8 @@ export const getDestinationsForUser = async ( urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } } + +export type BettingStreakData = { + streak: number + bonusAmount: number +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index e2959dda..34a8f218 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,5 +1,6 @@ import * as admin from 'firebase-admin' import { + BettingStreakData, getDestinationsForUser, Notification, notification_reason_types, @@ -686,6 +687,7 @@ export const createBettingStreakBonusNotification = async ( bet: Bet, contract: Contract, amount: number, + streak: number, idempotencyKey: string ) => { const privateUser = await getPrivateUser(user.id) @@ -719,6 +721,10 @@ export const createBettingStreakBonusNotification = async ( sourceContractId: contract.id, sourceContractTitle: contract.question, sourceContractCreatorUsername: contract.creatorUsername, + data: { + streak: streak, + bonusAmount: amount, + } as BettingStreakData, } return await notificationRef.set(removeUndefinedProps(notification)) } diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index f54d6475..5fe3fd62 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -26,6 +26,7 @@ import { APIError } from '../../common/api' import { User } from '../../common/user' import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' import { addHouseLiquidity } from './add-liquidity' +import { DAY_MS } from '../../common/util/time' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -80,12 +81,16 @@ const updateBettingStreak = async ( contract: Contract, eventId: string ) => { - const betStreakResetTime = getTodaysBettingStreakResetTime() + const now = Date.now() + const currentDateResetTime = currentDateBettingStreakResetTime() + // if now is before reset time, use yesterday's reset time + const lastDateResetTime = currentDateResetTime - DAY_MS + const betStreakResetTime = + now < currentDateResetTime ? lastDateResetTime : currentDateResetTime const lastBetTime = user?.lastBetTime ?? 0 - // If they've already bet after the reset time, or if we haven't hit the reset time yet - if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) - return + // If they've already bet after the reset time + if (lastBetTime > betStreakResetTime) return const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 // Otherwise, add 1 to their betting streak @@ -128,6 +133,7 @@ const updateBettingStreak = async ( bet, contract, bonusAmount, + newBettingStreak, eventId ) } @@ -170,13 +176,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( }) } - if (contract.mechanism === 'cpmm-1' && isNewUniqueBettor) { - await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) - } - // No need to give a bonus for the creator's bet if (!isNewUniqueBettor || bettor.id == contract.creatorId) return + if (contract.mechanism === 'cpmm-1') { + await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) + } + // Create combined txn for all new unique bettors const bonusTxnDetails = { contractId: contract.id, @@ -259,6 +265,6 @@ const notifyFills = async ( ) } -const getTodaysBettingStreakResetTime = () => { +const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index fcac8601..008f5df1 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -298,7 +298,7 @@ function IncomeNotificationGroupItem(props: { ...notificationsForSourceTitle[0], sourceText: sum.toString(), sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, - data: JSON.stringify(uniqueUsers), + data: { uniqueUsers }, } newNotifications.push(newNotification) } @@ -415,7 +415,7 @@ function IncomeNotificationItem(props: { const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' const isUniqueBettorBonus = sourceType === 'bonus' const userLinks: MultiUserLinkInfo[] = - isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] + isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : [] useEffect(() => { setNotificationsAsSeen([notification]) @@ -443,10 +443,11 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `liked` : `in likes on` } - const streakInDays = - Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 - ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT - : user?.currentBettingStreak ?? 0 + const streakInDays = notification.data?.streak + ? notification.data?.streak + : Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 + ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT + : user?.currentBettingStreak ?? 0 const bettingStreakText = sourceType === 'betting_streak_bonus' && (sourceText From edbae16c8ee8eb567aaaed379060c3f7357f7e99 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 08:56:05 -0600 Subject: [PATCH 20/37] Betting streak reset indicator --- .../profile/betting-streak-modal.tsx | 43 ++++++++++++++++++- web/components/user-page.tsx | 13 +++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index a137833c..4d1d63be 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -3,19 +3,44 @@ import { Col } from 'web/components/layout/col' import { BETTING_STREAK_BONUS_AMOUNT, BETTING_STREAK_BONUS_MAX, + BETTING_STREAK_RESET_HOUR, } from 'common/economy' import { formatMoney } from 'common/util/format' +import { User } from 'common/user' +import dayjs from 'dayjs' +import clsx from 'clsx' export function BettingStreakModal(props: { isOpen: boolean setOpen: (open: boolean) => void + currentUser?: User | null }) { - const { isOpen, setOpen } = props + const { isOpen, setOpen, currentUser } = props + const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) return ( <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> - <span className={'text-8xl'}>🔥</span> + <span + className={clsx( + 'text-8xl', + missingStreak ? 'grayscale' : 'grayscale-0' + )} + > + 🔥 + </span> + {missingStreak && ( + <Col className={' gap-2 text-center'}> + <span className={'font-bold'}> + You haven't predicted yet today! + </span> + <span className={'ml-2'}> + If the fire icon is gray, this means you haven't predicted yet + today to get your streak bonus. Get out there and make a + prediction! + </span> + </Col> + )} <span className="text-xl">Daily prediction streaks</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are they?</span> @@ -37,3 +62,17 @@ export function BettingStreakModal(props: { </Modal> ) } + +export function hasCompletedStreakToday(user: User) { + const now = dayjs().utc() + const utcTodayAtResetHour = now + .hour(BETTING_STREAK_RESET_HOUR) + .minute(0) + .second(0) + const utcYesterdayAtResetHour = utcTodayAtResetHour.subtract(1, 'day') + let resetTime = utcTodayAtResetHour.valueOf() + if (now.isBefore(utcTodayAtResetHour)) { + resetTime = utcYesterdayAtResetHour.valueOf() + } + return (user?.lastBetTime ?? 0) > resetTime +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 81aed562..5485267c 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -28,7 +28,10 @@ import { ReferralsButton } from 'web/components/referrals-button' import { formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' -import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' +import { + BettingStreakModal, + hasCompletedStreakToday, +} from 'web/components/profile/betting-streak-modal' import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' @@ -83,6 +86,7 @@ export function UserPage(props: { user: User }) { <BettingStreakModal isOpen={showBettingStreakModal} setOpen={setShowBettingStreakModal} + currentUser={currentUser} /> {showLoansModal && ( <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> @@ -139,7 +143,12 @@ export function UserPage(props: { user: User }) { <span>profit</span> </Col> <Col - className={'cursor-pointer items-center text-gray-500'} + className={clsx( + 'cursor-pointer items-center text-gray-500', + isCurrentUser && !hasCompletedStreakToday(user) + ? 'grayscale' + : 'grayscale-0' + )} onClick={() => setShowBettingStreakModal(true)} > <span>🔥 {user.currentBettingStreak ?? 0}</span> From 7ba2eab65ea927ba183a0ba6948a10649390cab2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 10:26:08 -0600 Subject: [PATCH 21/37] Rename user notification preferences --- common/notification.ts | 4 +- common/user.ts | 80 +++++++------------ firestore.rules | 2 +- functions/src/create-user.ts | 2 +- functions/src/emails.ts | 10 +-- .../create-new-notification-preferences.ts | 2 +- functions/src/scripts/create-private-users.ts | 2 +- .../update-notification-preferences.ts | 29 +++++++ web/components/notification-settings.tsx | 6 +- 9 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 functions/src/scripts/update-notification-preferences.ts diff --git a/common/notification.ts b/common/notification.ts index 47c55cc6..affa33cb 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -138,7 +138,7 @@ export const getDestinationsForUser = async ( privateUser: PrivateUser, reason: notification_reason_types | keyof notification_subscription_types ) => { - const notificationSettings = privateUser.notificationSubscriptionTypes + const notificationSettings = privateUser.notificationPreferences let destinations let subscriptionType: keyof notification_subscription_types | undefined if (Object.keys(notificationSettings).includes(reason)) { @@ -151,9 +151,11 @@ export const getDestinationsForUser = async ( ? notificationSettings[subscriptionType] : [] } + // const unsubscribeEndpoint = getFunctionUrl('unsubscribe') return { sendToEmail: destinations.includes('email'), sendToBrowser: destinations.includes('browser'), + // unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } } diff --git a/common/user.ts b/common/user.ts index 5d427744..7bd89906 100644 --- a/common/user.ts +++ b/common/user.ts @@ -65,9 +65,7 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string - /** @deprecated - use notificationSubscriptionTypes */ - notificationPreferences?: notification_subscribe_types - notificationSubscriptionTypes: notification_subscription_types + notificationPreferences: notification_subscription_types twitchInfo?: { twitchName: string controlToken: string @@ -142,9 +140,6 @@ export const getDefaultNotificationSettings = ( privateUser?: PrivateUser, noEmails?: boolean ) => { - const prevPref = privateUser?.notificationPreferences ?? 'all' - const wantsLess = prevPref === 'less' - const wantsAll = prevPref === 'all' const { unsubscribedFromCommentEmails, unsubscribedFromAnswerEmails, @@ -161,111 +156,96 @@ export const getDefaultNotificationSettings = ( return { // Watched Markets all_comments_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromCommentEmails ), all_answers_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromAnswerEmails ), // Comments - tips_on_your_comments: constructPref( - wantsAll || wantsLess, - !unsubscribedFromCommentEmails - ), - comments_by_followed_users_on_watched_markets: constructPref( - wantsAll, - false - ), + tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), + comments_by_followed_users_on_watched_markets: constructPref(true, false), all_replies_to_my_comments_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_replies_to_my_answers_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromCommentEmails ), // Answers answers_by_followed_users_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), answers_by_market_creator_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromAnswerEmails ), // On users' markets your_contract_closed: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromResolutionEmails ), // High priority all_comments_on_my_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_answers_on_my_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), - subsidized_your_market: constructPref(wantsAll || wantsLess, true), + subsidized_your_market: constructPref(true, true), // Market updates resolutions_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromResolutionEmails ), - market_updates_on_watched_markets: constructPref( - wantsAll || wantsLess, - false - ), + market_updates_on_watched_markets: constructPref(true, false), market_updates_on_watched_markets_with_shares_in: constructPref( - wantsAll || wantsLess, + true, false ), resolutions_on_watched_markets_with_shares_in: constructPref( - wantsAll || wantsLess, + true, !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 - ), + loan_income: constructPref(true, false), + betting_streaks: constructPref(true, false), + referral_bonuses: constructPref(true, true), + unique_bettors_on_your_contract: constructPref(true, false), tipped_comments_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), - tips_on_your_markets: constructPref(wantsAll || wantsLess, true), - limit_order_fills: constructPref(wantsAll || wantsLess, false), + tips_on_your_markets: constructPref(true, true), + limit_order_fills: constructPref(true, false), // General - tagged_user: constructPref(wantsAll || wantsLess, true), - on_new_follow: constructPref(wantsAll || wantsLess, true), - contract_from_followed_user: constructPref(wantsAll || wantsLess, true), + tagged_user: constructPref(true, true), + on_new_follow: constructPref(true, true), + contract_from_followed_user: constructPref(true, true), trending_markets: constructPref( false, !unsubscribedFromWeeklyTrendingEmails ), profit_loss_updates: constructPref(false, true), - probability_updates_on_watched_markets: constructPref( - wantsAll || wantsLess, - false - ), + probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref( false, !unsubscribedFromGenericEmails diff --git a/firestore.rules b/firestore.rules index 82392787..6f2ea90a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails', 'notificationSubscriptionTypes', 'twitchInfo']); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']); } match /private-users/{userId}/views/{viewId} { diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 71272222..ab5f014a 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -83,7 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, - notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), + notificationPreferences: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e9ef9630..bb9f7195 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -214,7 +214,7 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return @@ -247,7 +247,7 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return @@ -279,7 +279,7 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( + !privateUser.notificationPreferences.thank_you_for_purchases.includes( 'email' ) ) @@ -460,9 +460,7 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.trending_markets.includes( - 'email' - ) + !privateUser.notificationPreferences.trending_markets.includes('email') ) return diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts index a6bd1a0b..2796f2f7 100644 --- a/functions/src/scripts/create-new-notification-preferences.ts +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -17,7 +17,7 @@ async function main() { .collection('private-users') .doc(privateUser.id) .update({ - notificationSubscriptionTypes: getDefaultNotificationSettings( + notificationPreferences: getDefaultNotificationSettings( privateUser.id, privateUser, disableEmails diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index f9b8c3a1..21e117cf 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -21,7 +21,7 @@ async function main() { id: user.id, email, username, - notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), + notificationPreferences: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/functions/src/scripts/update-notification-preferences.ts b/functions/src/scripts/update-notification-preferences.ts new file mode 100644 index 00000000..0e2dc243 --- /dev/null +++ b/functions/src/scripts/update-notification-preferences.ts @@ -0,0 +1,29 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getPrivateUser } from 'functions/src/utils' +import { filterDefined } from 'common/lib/util/array' +import { FieldValue } from 'firebase-admin/firestore' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + // const privateUsers = await getAllPrivateUsers() + const privateUsers = filterDefined([ + await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), + ]) + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore.collection('private-users').doc(privateUser.id).update({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + notificationPreferences: privateUser.notificationSubscriptionTypes, + notificationSubscriptionTypes: FieldValue.delete(), + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 83ebf894..d18896bd 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -183,8 +183,8 @@ export function NotificationSettings(props: { toast .promise( updatePrivateUser(privateUser.id, { - notificationSubscriptionTypes: { - ...privateUser.notificationSubscriptionTypes, + notificationPreferences: { + ...privateUser.notificationPreferences, [subscriptionTypeKey]: destinations.includes(setting) ? destinations.filter((d) => d !== setting) : uniq([...destinations, setting]), @@ -240,7 +240,7 @@ export function NotificationSettings(props: { const getUsersSavedPreference = ( key: keyof notification_subscription_types ) => { - return privateUser.notificationSubscriptionTypes[key] ?? [] + return privateUser.notificationPreferences[key] ?? [] } const Section = memo(function Section(props: { From 050bd14e465fc23e5958e736bd4248db92f602be Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 10:29:48 -0600 Subject: [PATCH 22/37] Update script --- functions/src/scripts/update-notification-preferences.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/functions/src/scripts/update-notification-preferences.ts b/functions/src/scripts/update-notification-preferences.ts index 0e2dc243..efea57b8 100644 --- a/functions/src/scripts/update-notification-preferences.ts +++ b/functions/src/scripts/update-notification-preferences.ts @@ -1,18 +1,14 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getPrivateUser } from 'functions/src/utils' -import { filterDefined } from 'common/lib/util/array' +import { getAllPrivateUsers } from 'functions/src/utils' import { FieldValue } from 'firebase-admin/firestore' initAdmin() const firestore = admin.firestore() async function main() { - // const privateUsers = await getAllPrivateUsers() - const privateUsers = filterDefined([ - await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), - ]) + const privateUsers = await getAllPrivateUsers() await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() From 7aaacf4d505f62b13983033af1d0b8f48d390a93 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 14 Sep 2022 13:19:12 -0700 Subject: [PATCH 23/37] Center tweets --- web/components/editor/tweet-embed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/editor/tweet-embed.tsx b/web/components/editor/tweet-embed.tsx index 91b2fa65..fb7d7810 100644 --- a/web/components/editor/tweet-embed.tsx +++ b/web/components/editor/tweet-embed.tsx @@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: { const tweetId = props.node.attrs.tweetId.slice(1) return ( - <NodeViewWrapper className="tiptap-tweet"> + <NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto"> <TwitterTweetEmbed tweetId={tweetId} /> </NodeViewWrapper> ) From 68b0539fc1ca5399550b3305742c43fb97c8d229 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 14 Sep 2022 15:06:11 -0700 Subject: [PATCH 24/37] Enable search exclusion and exact searches like `-musk` to remove Elon results or `"eth"` for Ethereum results --- web/components/contract-search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 5bd69057..7f64b26b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -164,6 +164,7 @@ export function ContractSearch(props: { numericFilters, page: requestedPage, hitsPerPage: 20, + advancedSyntax: true, }) // if there's a more recent request, forget about this one if (id === requestId.current) { From 3efd968058176905018cdd0af8beab9894a187fc Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 17:17:32 -0600 Subject: [PATCH 25/37] Allow one-click unsubscribe, slight refactor --- common/notification.ts | 219 ++++++++---- common/user-notification-preferences.ts | 243 +++++++++++++ common/user.ts | 174 +--------- functions/src/create-notification.ts | 24 +- functions/src/create-user.ts | 9 +- functions/src/email-templates/500-mana.html | 321 ------------------ .../src/email-templates/creating-market.html | 2 +- .../email-templates/interesting-markets.html | 2 +- .../market-answer-comment.html | 2 +- .../src/email-templates/market-answer.html | 2 +- .../src/email-templates/market-close.html | 2 +- .../src/email-templates/market-comment.html | 2 +- .../market-resolved-no-bets.html | 2 +- .../src/email-templates/market-resolved.html | 2 +- .../new-market-from-followed-user.html | 2 +- .../email-templates/new-unique-bettor.html | 2 +- .../email-templates/new-unique-bettors.html | 2 +- functions/src/email-templates/one-week.html | 2 +- functions/src/email-templates/thank-you.html | 2 +- functions/src/email-templates/welcome.html | 2 +- functions/src/emails.ts | 61 ++-- .../create-new-notification-preferences.ts | 4 +- functions/src/scripts/create-private-users.ts | 5 +- functions/src/unsubscribe.ts | 252 +++++++++++--- web/components/notification-settings.tsx | 132 ++++--- 25 files changed, 723 insertions(+), 749 deletions(-) create mode 100644 common/user-notification-preferences.ts delete mode 100644 functions/src/email-templates/500-mana.html diff --git a/common/notification.ts b/common/notification.ts index affa33cb..c34f5b9c 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,5 +1,4 @@ -import { notification_subscription_types, PrivateUser } from './user' -import { DOMAIN } from './envs/constants' +import { notification_preference } from './user-notification-preferences' export type Notification = { id: string @@ -29,6 +28,7 @@ export type Notification = { isSeenOnHref?: string } + export type notification_source_types = | 'contract' | 'comment' @@ -54,7 +54,7 @@ export type notification_source_update_types = | 'deleted' | 'closed' -/* Optional - if possible use a keyof notification_subscription_types */ +/* Optional - if possible use a notification_preference */ export type notification_reason_types = | 'tagged_user' | 'on_new_follow' @@ -92,75 +92,152 @@ export type notification_reason_types = | '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.notificationPreferences - 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] - : [] - } - // const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email'), - sendToBrowser: destinations.includes('browser'), - // unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, - } -} - export type BettingStreakData = { streak: number bonusAmount: number } + +type notification_descriptions = { + [key in notification_preference]: { + simple: string + detailed: string + } +} +export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { + all_answers_on_my_markets: { + simple: 'Answers on your markets', + detailed: 'Answers on your own markets', + }, + all_comments_on_my_markets: { + simple: 'Comments on your markets', + detailed: 'Comments on your own markets', + }, + answers_by_followed_users_on_watched_markets: { + simple: 'Only answers by users you follow', + detailed: "Only answers by users you follow on markets you're watching", + }, + answers_by_market_creator_on_watched_markets: { + simple: 'Only answers by market creator', + detailed: "Only answers by market creator on markets you're watching", + }, + betting_streaks: { + simple: 'For predictions made over consecutive days', + detailed: 'Bonuses for predictions made over consecutive days', + }, + comments_by_followed_users_on_watched_markets: { + simple: 'Only comments by users you follow', + detailed: + 'Only comments by users that you follow on markets that you watch', + }, + contract_from_followed_user: { + simple: 'New markets from users you follow', + detailed: 'New markets from users you follow', + }, + limit_order_fills: { + simple: 'Limit order fills', + detailed: 'When your limit order is filled by another user', + }, + loan_income: { + simple: 'Automatic loans from your predictions in unresolved markets', + detailed: + 'Automatic loans from your predictions that are locked in unresolved markets', + }, + market_updates_on_watched_markets: { + simple: 'All creator updates', + detailed: 'All market updates made by the creator', + }, + market_updates_on_watched_markets_with_shares_in: { + simple: "Only creator updates on markets that you're invested in", + detailed: + "Only updates made by the creator on markets that you're invested in", + }, + on_new_follow: { + simple: 'A user followed you', + detailed: 'A user followed you', + }, + onboarding_flow: { + simple: 'Emails to help you get started using Manifold', + detailed: 'Emails to help you learn how to use Manifold', + }, + probability_updates_on_watched_markets: { + simple: 'Large changes in probability on markets that you watch', + detailed: 'Large changes in probability on markets that you watch', + }, + profit_loss_updates: { + simple: 'Weekly profit and loss updates', + detailed: 'Weekly profit and loss updates', + }, + referral_bonuses: { + simple: 'For referring new users', + detailed: 'Bonuses you receive from referring a new user', + }, + resolutions_on_watched_markets: { + simple: 'All market resolutions', + detailed: "All resolutions on markets that you're watching", + }, + resolutions_on_watched_markets_with_shares_in: { + simple: "Only market resolutions that you're invested in", + detailed: + "Only resolutions of markets you're watching and that you're invested in", + }, + subsidized_your_market: { + simple: 'Your market was subsidized', + detailed: 'When someone subsidizes your market', + }, + tagged_user: { + simple: 'A user tagged you', + detailed: 'When another use tags you', + }, + thank_you_for_purchases: { + simple: 'Thank you notes for your purchases', + detailed: 'Thank you notes for your purchases', + }, + tipped_comments_on_watched_markets: { + simple: 'Only highly tipped comments on markets that you watch', + detailed: 'Only highly tipped comments on markets that you watch', + }, + tips_on_your_comments: { + simple: 'Tips on your comments', + detailed: 'Tips on your comments', + }, + tips_on_your_markets: { + simple: 'Tips/Likes on your markets', + detailed: 'Tips/Likes on your markets', + }, + trending_markets: { + simple: 'Weekly interesting markets', + detailed: 'Weekly interesting markets', + }, + unique_bettors_on_your_contract: { + simple: 'For unique predictors on your markets', + detailed: 'Bonuses for unique predictors on your markets', + }, + your_contract_closed: { + simple: 'Your market has closed and you need to resolve it', + detailed: 'Your market has closed and you need to resolve it', + }, + all_comments_on_watched_markets: { + simple: 'All new comments', + detailed: 'All new comments on markets you follow', + }, + all_comments_on_contracts_with_shares_in_on_watched_markets: { + simple: `Only on markets you're invested in`, + detailed: `Comments on markets that you're watching and you're invested in`, + }, + all_replies_to_my_comments_on_watched_markets: { + simple: 'Only replies to your comments', + detailed: "Only replies to your comments on markets you're watching", + }, + all_replies_to_my_answers_on_watched_markets: { + simple: 'Only replies to your answers', + detailed: "Only replies to your answers on markets you're watching", + }, + all_answers_on_watched_markets: { + simple: 'All new answers', + detailed: "All new answers on markets you're watching", + }, + all_answers_on_contracts_with_shares_in_on_watched_markets: { + simple: `Only on markets you're invested in`, + detailed: `Answers on markets that you're watching and that you're invested in`, + }, +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts new file mode 100644 index 00000000..e2402ea9 --- /dev/null +++ b/common/user-notification-preferences.ts @@ -0,0 +1,243 @@ +import { filterDefined } from './util/array' +import { notification_reason_types } from './notification' +import { getFunctionUrl } from './api' +import { DOMAIN } from './envs/constants' +import { PrivateUser } from './user' + +export type notification_destination_types = 'email' | 'browser' +export type notification_preference = keyof notification_preferences +export type notification_preferences = { + // 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 const getDefaultNotificationPreferences = ( + userId: string, + privateUser?: PrivateUser, + noEmails?: boolean +) => { + 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( + true, + !unsubscribedFromCommentEmails + ), + all_answers_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + + // Comments + tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), + comments_by_followed_users_on_watched_markets: constructPref(true, false), + all_replies_to_my_comments_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_replies_to_my_answers_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + + // Answers + answers_by_followed_users_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + answers_by_market_creator_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + + // On users' markets + your_contract_closed: constructPref( + true, + !unsubscribedFromResolutionEmails + ), // High priority + all_comments_on_my_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_answers_on_my_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + subsidized_your_market: constructPref(true, true), + + // Market updates + resolutions_on_watched_markets: constructPref( + true, + !unsubscribedFromResolutionEmails + ), + market_updates_on_watched_markets: constructPref(true, false), + market_updates_on_watched_markets_with_shares_in: constructPref( + true, + false + ), + resolutions_on_watched_markets_with_shares_in: constructPref( + true, + !unsubscribedFromResolutionEmails + ), + + //Balance Changes + loan_income: constructPref(true, false), + betting_streaks: constructPref(true, false), + referral_bonuses: constructPref(true, true), + unique_bettors_on_your_contract: constructPref(true, false), + tipped_comments_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + tips_on_your_markets: constructPref(true, true), + limit_order_fills: constructPref(true, false), + + // General + tagged_user: constructPref(true, true), + on_new_follow: constructPref(true, true), + contract_from_followed_user: constructPref(true, true), + trending_markets: constructPref( + false, + !unsubscribedFromWeeklyTrendingEmails + ), + profit_loss_updates: constructPref(false, true), + probability_updates_on_watched_markets: constructPref(true, false), + thank_you_for_purchases: constructPref( + false, + !unsubscribedFromGenericEmails + ), + onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + } as notification_preferences +} + +// 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 +const notificationReasonToSubscriptionType: Partial< + Record<notification_reason_types, notification_preference> +> = { + 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 getNotificationDestinationsForUser = ( + privateUser: PrivateUser, + reason: notification_reason_types | notification_preference +) => { + const notificationSettings = privateUser.notificationPreferences + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') + return { + sendToEmail: destinations.includes('email'), + sendToBrowser: destinations.includes('browser'), + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } +} diff --git a/common/user.ts b/common/user.ts index 7bd89906..16a2b437 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,4 +1,4 @@ -import { filterDefined } from './util/array' +import { notification_preferences } from './user-notification-preferences' export type User = { id: string @@ -65,7 +65,7 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string - notificationPreferences: notification_subscription_types + notificationPreferences: notification_preferences twitchInfo?: { twitchName: string controlToken: string @@ -73,57 +73,6 @@ export type PrivateUser = { } } -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 = { investmentValue: number balance: number @@ -134,122 +83,3 @@ 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 { - 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( - true, - !unsubscribedFromCommentEmails - ), - all_answers_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - - // Comments - tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), - comments_by_followed_users_on_watched_markets: constructPref(true, false), - all_replies_to_my_comments_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_replies_to_my_answers_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - - // Answers - answers_by_followed_users_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - answers_by_market_creator_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - - // On users' markets - your_contract_closed: constructPref( - true, - !unsubscribedFromResolutionEmails - ), // High priority - all_comments_on_my_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_answers_on_my_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - subsidized_your_market: constructPref(true, true), - - // Market updates - resolutions_on_watched_markets: constructPref( - true, - !unsubscribedFromResolutionEmails - ), - market_updates_on_watched_markets: constructPref(true, false), - market_updates_on_watched_markets_with_shares_in: constructPref( - true, - false - ), - resolutions_on_watched_markets_with_shares_in: constructPref( - true, - !unsubscribedFromResolutionEmails - ), - - //Balance Changes - loan_income: constructPref(true, false), - betting_streaks: constructPref(true, false), - referral_bonuses: constructPref(true, true), - unique_bettors_on_your_contract: constructPref(true, false), - tipped_comments_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - tips_on_your_markets: constructPref(true, true), - limit_order_fills: constructPref(true, false), - - // General - tagged_user: constructPref(true, true), - on_new_follow: constructPref(true, true), - contract_from_followed_user: constructPref(true, true), - trending_markets: constructPref( - false, - !unsubscribedFromWeeklyTrendingEmails - ), - profit_loss_updates: constructPref(false, true), - probability_updates_on_watched_markets: constructPref(true, false), - thank_you_for_purchases: constructPref( - false, - !unsubscribedFromGenericEmails - ), - onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), - } as notification_subscription_types -} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 34a8f218..ba9fa5c4 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,7 +1,6 @@ import * as admin from 'firebase-admin' import { BettingStreakData, - getDestinationsForUser, Notification, notification_reason_types, } from '../../common/notification' @@ -27,6 +26,7 @@ import { sendNewUniqueBettorsEmail, } from './emails' import { filterDefined } from '../../common/util/array' +import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -66,7 +66,7 @@ export const createNotification = async ( const { reason } = userToReasonTexts[userId] const privateUser = await getPrivateUser(userId) if (!privateUser) continue - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) @@ -236,7 +236,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( return const privateUser = await getPrivateUser(userId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) @@ -468,7 +468,7 @@ export const createTipNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'tip_received' ) @@ -513,7 +513,7 @@ export const createBetFillNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'bet_fill' ) @@ -558,7 +558,7 @@ export const createReferralNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'you_referred_user' ) @@ -612,7 +612,7 @@ export const createLoanIncomeNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'loan_income' ) @@ -650,7 +650,7 @@ export const createChallengeAcceptedNotification = async ( ) => { const privateUser = await getPrivateUser(challengeCreator.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'challenge_accepted' ) @@ -692,7 +692,7 @@ export const createBettingStreakBonusNotification = async ( ) => { const privateUser = await getPrivateUser(user.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'betting_streak_incremented' ) @@ -739,7 +739,7 @@ export const createLikeNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'liked_and_tipped_your_contract' ) @@ -786,7 +786,7 @@ export const createUniqueBettorBonusNotification = async ( ) => { const privateUser = await getPrivateUser(contractCreatorId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'unique_bettors_on_your_contract' ) @@ -876,7 +876,7 @@ export const createNewContractNotification = async ( ) => { const privateUser = await getPrivateUser(userId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab5f014a..ab70b4e6 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,11 +1,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { - getDefaultNotificationSettings, - PrivateUser, - User, -} from '../../common/user' +import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -22,6 +18,7 @@ import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' +import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences' const bodySchema = z.object({ deviceToken: z.string().optional(), @@ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, - notificationPreferences: getDefaultNotificationSettings(auth.uid), + notificationPreferences: getDefaultNotificationPreferences(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html deleted file mode 100644 index c8f6a171..00000000 --- a/functions/src/email-templates/500-mana.html +++ /dev/null @@ -1,321 +0,0 @@ -<!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>Manifold Markets 7th Day Anniversary Gift! - - - - - - - - - - - - - - - -
- -
- - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - -
-
-
-

- Hi {{name}},

-
-
-
-

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

-
-
-

-
- - - - -
- - - - -
- - Claim M$500 - -
-
-
-
-

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

- -

 

-

Cheers, -

-

David - from Manifold

-

 

-
-
-
-

- -

 

-

Cheers,

-

David from Manifold

-

 

-
-
-
- -
-
- -
- - - -
- -
- - - - diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index 7c3e653d..0cee6269 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -443,7 +443,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index a19aa7c3..4b98730f 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -529,7 +529,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.
- - - -
-
- - - - - -
- -
- - - - - - -
- - - - - - - - - -
-
-

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

-
-
-
-
-
-
-
- -
- - - - - - \ No newline at end of file diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index c73f7458..bf163f69 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -494,7 +494,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index b2d7f727..e3d42b9d 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -369,7 +369,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index ee7976b0..4abd225e 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -487,7 +487,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 23e20dac..ce0669f1 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -369,7 +369,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html index ff5f541f..5d886adf 100644 --- a/functions/src/email-templates/market-resolved-no-bets.html +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -470,7 +470,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index de29a0f1..767202b6 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -502,7 +502,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html index 877d554f..49633fb2 100644 --- a/functions/src/email-templates/new-market-from-followed-user.html +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -318,7 +318,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html index 30da8b99..51026121 100644 --- a/functions/src/email-templates/new-unique-bettor.html +++ b/functions/src/email-templates/new-unique-bettor.html @@ -376,7 +376,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html index eb4c04e2..09c44d03 100644 --- a/functions/src/email-templates/new-unique-bettors.html +++ b/functions/src/email-templates/new-unique-bettors.html @@ -480,7 +480,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index b8e233d5..e7d14a7e 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -283,7 +283,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html index 7ac72d0a..beef11ee 100644 --- a/functions/src/email-templates/thank-you.html +++ b/functions/src/email-templates/thank-you.html @@ -218,7 +218,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index dccec695..d6caaa0c 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -290,7 +290,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index bb9f7195..98309ebe 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { Contract } from '../../common/contract' -import { - notification_subscription_types, - PrivateUser, - User, -} from '../../common/user' +import { PrivateUser, User } from '../../common/user' import { formatLargeNumber, formatMoney, @@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' -import { - notification_reason_types, - getDestinationsForUser, -} from '../../common/notification' +import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' +import { + getNotificationDestinationsForUser, + notification_preference, +} from '../../common/user-notification-preferences' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser || !privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) @@ -154,7 +153,7 @@ export const sendWelcomeEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( @@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( privateUser.email, @@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( privateUser.email, @@ -289,7 +288,7 @@ export const sendThankYouEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'thank_you_for_purchases' as keyof notification_subscription_types + 'thank_you_for_purchases' as notification_preference }` return await sendTemplateEmail( @@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return @@ -350,8 +351,10 @@ export const sendNewCommentEmail = async ( answerText?: string, answerId?: string ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser || !privateUser.email || !sendToEmail) return const { question } = contract @@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async ( // Don't send the creator's own answers. if (privateUser.id === creatorId) return - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract @@ -465,7 +470,7 @@ export const sendInterestingMarketsEmail = async ( return const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'trending_markets' as keyof notification_subscription_types + 'trending_markets' as notification_preference }` const { name } = user @@ -516,8 +521,10 @@ export const sendNewFollowedMarketEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) if (!user) return @@ -553,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async ( userBets: Dictionary<[Bet, ...Bet[]]>, bonusAmount: number ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) if (!user) return diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts index 2796f2f7..4ba2e25e 100644 --- a/functions/src/scripts/create-new-notification-preferences.ts +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -1,8 +1,8 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getDefaultNotificationSettings } from 'common/user' import { getAllPrivateUsers, isProd } from 'functions/src/utils' +import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' initAdmin() const firestore = admin.firestore() @@ -17,7 +17,7 @@ async function main() { .collection('private-users') .doc(privateUser.id) .update({ - notificationPreferences: getDefaultNotificationSettings( + notificationPreferences: getDefaultNotificationPreferences( privateUser.id, privateUser, disableEmails diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index 21e117cf..762e801a 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,8 +3,9 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' +import { PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' +import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' const firestore = admin.firestore() @@ -21,7 +22,7 @@ async function main() { id: user.id, email, username, - notificationPreferences: getDefaultNotificationSettings(user.id), + notificationPreferences: getDefaultNotificationPreferences(user.id), } if (user.totalDeposits === undefined) { diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index da7b507f..418282c7 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,79 +1,227 @@ import * as admin from 'firebase-admin' import { EndpointDefinition } from './api' -import { getUser } from './utils' +import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' +import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' +import { notification_preference } from '../../common/user-notification-preferences' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, handler: async (req, res) => { const id = req.query.id as string - let type = req.query.type as string + const type = req.query.type as string if (!id || !type) { - res.status(400).send('Empty id or type parameter.') + res.status(400).send('Empty id or subscription type parameter.') + return + } + console.log(`Unsubscribing ${id} from ${type}`) + const notificationSubscriptionType = type as notification_preference + if (notificationSubscriptionType === undefined) { + res.status(400).send('Invalid subscription type parameter.') return } - if (type === 'market-resolved') type = 'market-resolve' - - if ( - ![ - 'market-resolve', - 'market-comment', - 'market-answer', - 'generic', - 'weekly-trending', - ].includes(type) - ) { - res.status(400).send('Invalid type parameter.') - return - } - - const user = await getUser(id) + const user = await getPrivateUser(id) if (!user) { res.send('This user is not currently subscribed or does not exist.') return } - const { name } = user + const previousDestinations = + user.notificationPreferences[notificationSubscriptionType] + + console.log(previousDestinations) + const { email } = user const update: Partial = { - ...(type === 'market-resolve' && { - unsubscribedFromResolutionEmails: true, - }), - ...(type === 'market-comment' && { - unsubscribedFromCommentEmails: true, - }), - ...(type === 'market-answer' && { - unsubscribedFromAnswerEmails: true, - }), - ...(type === 'generic' && { - unsubscribedFromGenericEmails: true, - }), - ...(type === 'weekly-trending' && { - unsubscribedFromWeeklyTrendingEmails: true, - }), + notificationPreferences: { + ...user.notificationPreferences, + [notificationSubscriptionType]: previousDestinations.filter( + (destination) => destination !== 'email' + ), + }, } await firestore.collection('private-users').doc(id).update(update) - if (type === 'market-resolve') - res.send( - `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` - ) - else if (type === 'market-comment') - res.send( - `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` - ) - else if (type === 'market-answer') - res.send( - `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` - ) - else if (type === 'weekly-trending') - res.send( - `${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.` - ) - else res.send(`${name}, you have been unsubscribed.`) + res.send( + ` + + + + + Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ + +` + ) }, } diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index d18896bd..8730ce7f 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -1,11 +1,7 @@ import React, { memo, ReactNode, useEffect, useState } from 'react' import { Row } from 'web/components/layout/row' import clsx from 'clsx' -import { - notification_subscription_types, - notification_destination_types, - PrivateUser, -} from 'common/user' +import { PrivateUser } from 'common/user' import { updatePrivateUser } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { @@ -30,6 +26,11 @@ import { usePersistentState, } from 'web/hooks/use-persistent-state' import { safeLocalStorage } from 'web/lib/util/local' +import { NOTIFICATION_DESCRIPTIONS } from 'common/notification' +import { + notification_destination_types, + notification_preference, +} from 'common/user-notification-preferences' export function NotificationSettings(props: { navigateToSection: string | undefined @@ -38,7 +39,7 @@ export function NotificationSettings(props: { const { navigateToSection, privateUser } = props const [showWatchModal, setShowWatchModal] = useState(false) - const emailsEnabled: Array = [ + const emailsEnabled: Array = [ 'all_comments_on_watched_markets', 'all_replies_to_my_comments_on_watched_markets', 'all_comments_on_contracts_with_shares_in_on_watched_markets', @@ -74,7 +75,7 @@ export function NotificationSettings(props: { // 'probability_updates_on_watched_markets', // 'limit_order_fills', ] - const browserDisabled: Array = [ + const browserDisabled: Array = [ 'trending_markets', 'profit_loss_updates', 'onboarding_flow', @@ -83,91 +84,82 @@ export function NotificationSettings(props: { type SectionData = { label: string - subscriptionTypeToDescription: { - [key in keyof Partial]: string - } + subscriptionTypes: Partial[] } 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`, + subscriptionTypes: [ + 'all_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', // 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', - }, + 'all_replies_to_my_comments_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + ], } 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', - }, + subscriptionTypes: [ + 'all_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + ], } 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', - }, + subscriptionTypes: [ + 'market_updates_on_watched_markets', + 'market_updates_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + 'resolutions_on_watched_markets_with_shares_in', + ], } 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', - }, + subscriptionTypes: [ + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + 'subsidized_your_market', + 'tips_on_your_markets', + ], } const bonuses: SectionData = { label: 'Bonuses', - subscriptionTypeToDescription: { - betting_streaks: 'Prediction streak bonuses', - referral_bonuses: 'Referral bonuses from referring users', - unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', - }, + subscriptionTypes: [ + 'betting_streaks', + 'referral_bonuses', + 'unique_bettors_on_your_contract', + ], } 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', - }, + subscriptionTypes: [ + 'loan_income', + 'limit_order_fills', + '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', - }, + subscriptionTypes: [ + 'tagged_user', + 'on_new_follow', + 'contract_from_followed_user', + ], } 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', - }, + subscriptionTypes: [ + 'trending_markets', + 'thank_you_for_purchases', + 'onboarding_flow', + ], } function NotificationSettingLine(props: { description: string - subscriptionTypeKey: keyof notification_subscription_types + subscriptionTypeKey: notification_preference destinations: notification_destination_types[] }) { const { description, subscriptionTypeKey, destinations } = props @@ -237,9 +229,7 @@ export function NotificationSettings(props: { ) } - const getUsersSavedPreference = ( - key: keyof notification_subscription_types - ) => { + const getUsersSavedPreference = (key: notification_preference) => { return privateUser.notificationPreferences[key] ?? [] } @@ -248,17 +238,17 @@ export function NotificationSettings(props: { data: SectionData }) { const { icon, data } = props - const { label, subscriptionTypeToDescription } = data + const { label, subscriptionTypes } = data const expand = navigateToSection && - Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + Object.keys(subscriptionTypes).includes(navigateToSection) // Not sure how to prevent re-render (and collapse of an open section) // due to a private user settings change. Just going to persist expanded state here const [expanded, setExpanded] = usePersistentState(expand ?? false, { key: 'NotificationsSettingsSection-' + - Object.keys(subscriptionTypeToDescription).join('-'), + Object.keys(subscriptionTypes).join('-'), store: storageStore(safeLocalStorage()), }) @@ -287,13 +277,13 @@ export function NotificationSettings(props: { )} - {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + {subscriptionTypes.map((subType) => ( ))} From 9aa56dd19300e084361d14cc606ac690aa434f7d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 17:25:17 -0600 Subject: [PATCH 26/37] Only show prev opened notif setting section --- web/components/notification-settings.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 8730ce7f..b806dfb2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -241,14 +241,12 @@ export function NotificationSettings(props: { const { label, subscriptionTypes } = data const expand = navigateToSection && - Object.keys(subscriptionTypes).includes(navigateToSection) + subscriptionTypes.includes(navigateToSection as notification_preference) // Not sure how to prevent re-render (and collapse of an open section) // due to a private user settings change. Just going to persist expanded state here const [expanded, setExpanded] = usePersistentState(expand ?? false, { - key: - 'NotificationsSettingsSection-' + - Object.keys(subscriptionTypes).join('-'), + key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'), store: storageStore(safeLocalStorage()), }) From ccf02bdba8f565e94fbb01fb9ac5cb7c9de93fc2 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 14 Sep 2022 22:28:40 -0500 Subject: [PATCH 27/37] Inga/admin rules resolve (#880) * Giving admin permission to resolve all markets that have closed after 7 days. --- common/envs/constants.ts | 5 ++- common/envs/prod.ts | 1 + firestore.rules | 3 +- functions/src/resolve-market.ts | 9 ++++-- .../answers/answer-resolve-panel.tsx | 20 ++++++++++-- web/components/answers/answers-panel.tsx | 28 ++++++++++------- web/components/numeric-resolution-panel.tsx | 20 +++++++++--- web/components/resolution-panel.tsx | 11 +++++-- web/pages/[username]/[contractSlug].tsx | 31 +++++++++++++++---- 9 files changed, 99 insertions(+), 29 deletions(-) diff --git a/common/envs/constants.ts b/common/envs/constants.ts index ba460d58..0502322a 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) { } // TODO: Before open sourcing, we should turn these into env vars -export function isAdmin(email: string) { +export function isAdmin(email?: string) { + if (!email) { + return false + } return ENV_CONFIG.adminEmails.includes(email) } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index b3b552eb..6bf781b7 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -74,6 +74,7 @@ export const PROD_CONFIG: EnvConfig = { 'iansphilips@gmail.com', // Ian 'd4vidchee@gmail.com', // D4vid 'federicoruizcassarino@gmail.com', // Fede + 'ingawei@gmail.com', //Inga ], visibility: 'PUBLIC', diff --git a/firestore.rules b/firestore.rules index 6f2ea90a..08214b10 100644 --- a/firestore.rules +++ b/firestore.rules @@ -14,7 +14,8 @@ service cloud.firestore { 'manticmarkets@gmail.com', 'iansphilips@gmail.com', 'd4vidchee@gmail.com', - 'federicoruizcassarino@gmail.com' + 'federicoruizcassarino@gmail.com', + 'ingawei@gmail.com' ] } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b867b609..44293898 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -16,7 +16,7 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' -import { isManifoldId } from '../../common/envs/constants' +import { isAdmin, isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' @@ -76,13 +76,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { throw new APIError(404, 'No contract exists with the provided ID') const contract = contractSnap.data() as Contract const { creatorId, closeTime } = contract + const firebaseUser = await admin.auth().getUser(auth.uid) const { value, resolutions, probabilityInt, outcome } = getResolutionParams( contract, req.body ) - if (creatorId !== auth.uid && !isManifoldId(auth.uid)) + if ( + creatorId !== auth.uid && + !isManifoldId(auth.uid) && + !isAdmin(firebaseUser.email) + ) throw new APIError(403, 'User is not creator of contract') if (contract.resolution) throw new APIError(400, 'Contract already resolved') diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 0a4ac1e1..4594ea35 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button' import { removeUndefinedProps } from 'common/util/object' export function AnswerResolvePanel(props: { + isAdmin: boolean + isCreator: boolean contract: FreeResponseContract | MultipleChoiceContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( @@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: { ) => void chosenAnswers: { [answerId: string]: number } }) { - const { contract, resolveOption, setResolveOption, chosenAnswers } = props + const { + contract, + resolveOption, + setResolveOption, + chosenAnswers, + isAdmin, + isCreator, + } = props const answers = Object.keys(chosenAnswers) const [isSubmitting, setIsSubmitting] = useState(false) @@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: { return ( -
Resolve your market
+ +
Resolve your market
+ {isAdmin && !isCreator && ( + + ADMIN + + )} +
)} - {user?.id === creatorId && !resolution && ( - <> - - - - )} + {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && + !resolution && ( + <> + + + + )} ) } diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index dce36ab9..70fbf01f 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -12,11 +12,13 @@ import { BucketInput } from './bucket-input' import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { + isAdmin: boolean + isCreator: boolean creator: User contract: NumericContract | PseudoNumericContract className?: string }) { - const { contract, className } = props + const { contract, className, isAdmin, isCreator } = props const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< @@ -78,10 +80,20 @@ export function NumericResolutionPanel(props: { : 'btn-disabled' return ( - -
Resolve market
+ + {isAdmin && !isCreator && ( + + ADMIN + + )} +
Resolve market
-
Outcome
+
Outcome
diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 5a7b993e..6f36331e 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -12,11 +12,13 @@ import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' export function ResolutionPanel(props: { + isAdmin: boolean + isCreator: boolean creator: User contract: BinaryContract className?: string }) { - const { contract, className } = props + const { contract, className, isAdmin, isCreator } = props // const earnedFees = // contract.mechanism === 'dpm-2' @@ -66,7 +68,12 @@ export function ResolutionPanel(props: { : 'btn-disabled' return ( - + + {isAdmin && !isCreator && ( + + ADMIN + + )}
Resolve market
Outcome
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index de0c7807..2c011c90 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -45,6 +45,8 @@ import { import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' import { usePrefetch } from 'web/hooks/use-prefetch' +import { useAdmin } from 'web/hooks/use-admin' +import dayjs from 'dayjs' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -110,19 +112,28 @@ export default function ContractPage(props: { ) } +// requires an admin to resolve a week after market closes +export function needsAdminToResolve(contract: Contract) { + return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7 +} + export function ContractPageSidebar(props: { user: User | null | undefined contract: Contract }) { const { contract, user } = props const { creatorId, isResolved, outcomeType } = contract - const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) - const allowResolve = !isResolved && isCreator && !!user + const isAdmin = useAdmin() + const allowResolve = + !isResolved && + (isCreator || (needsAdminToResolve(contract) && isAdmin)) && + !!user + const hasSidePanel = (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) @@ -139,9 +150,19 @@ export function ContractPageSidebar(props: { ))} {allowResolve && (isNumeric || isPseudoNumeric ? ( - + ) : ( - + ))} ) : null @@ -154,10 +175,8 @@ export function ContractPageContent( } ) { const { backToHome, comments, user } = props - const contract = useContractWithPreload(props.contract) ?? props.contract usePrefetch(user?.id) - useTracking( 'view market', { From 8aaaf5e9e05e138d4ec8dbf436ebf594d9fa0792 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 15 Sep 2022 01:46:58 -0500 Subject: [PATCH 28/37] Inga/bettingfix (#879) * making betting action panels much more minimal, particularly for mobile * added tiny follow button --- web/components/button.tsx | 5 +- web/components/contract/contract-details.tsx | 252 ++++++++++-------- .../contract/contract-info-dialog.tsx | 11 +- web/components/contract/contract-overview.tsx | 12 +- .../contract/extra-contract-actions-row.tsx | 57 +--- .../contract/like-market-button.tsx | 20 +- web/components/follow-button.tsx | 67 ++++- web/components/follow-market-button.tsx | 16 +- web/pages/[username]/[contractSlug].tsx | 2 - web/tailwind.config.js | 2 + 10 files changed, 262 insertions(+), 182 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index cb39cba8..ea9a3e88 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,6 +11,7 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' + | 'highlight-blue' export function Button(props: { className?: string @@ -56,7 +57,9 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', + 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', + color === 'highlight-blue' && + 'text-highlight-blue border-none shadow-none', className )} disabled={disabled} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index e28ab41a..fad62c86 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,9 +1,4 @@ -import { - ClockIcon, - DatabaseIcon, - PencilIcon, - UserGroupIcon, -} from '@heroicons/react/outline' +import { ClockIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' -import { ContractInfoDialog } from './contract-info-dialog' import NewContractBadge from '../new-contract-badge' -import { UserFollowButton } from '../follow-button' +import { MiniUserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' @@ -34,6 +28,8 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { useWindowSize } from 'web/hooks/use-window-size' +import { ExtraContractActionsRow } from './extra-contract-actions-row' +import { PlusCircleIcon } from '@heroicons/react/solid' export type ShowTime = 'resolve-date' | 'close-date' @@ -104,90 +100,174 @@ export function AvatarDetails(props: { ) } +export function useIsMobile() { + const { width } = useWindowSize() + return (width ?? 0) < 600 +} + export function ContractDetails(props: { contract: Contract disabled?: boolean }) { const { contract, disabled } = props - const { - closeTime, - creatorName, - creatorUsername, - creatorId, - creatorAvatarUrl, - resolutionTime, - } = contract - const { volumeLabel, resolvedDate } = contractMetrics(contract) + const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract + const { resolvedDate } = contractMetrics(contract) const user = useUser() const isCreator = user?.id === creatorId + const isMobile = useIsMobile() + + return ( + + + + {!disabled && ( +
+ +
+ )} + + + {disabled ? ( + creatorName + ) : ( + + )} + + + + {!isMobile && ( + + )} + + +
+ +
+
+ {/* GROUPS */} + {isMobile && ( +
+ +
+ )} + + ) +} + +export function CloseOrResolveTime(props: { + contract: Contract + resolvedDate: any + isCreator: boolean +}) { + const { contract, resolvedDate, isCreator } = props + const { resolutionTime, closeTime } = contract + console.log(closeTime, resolvedDate) + if (!!closeTime || !!resolvedDate) { + return ( + + {resolvedDate && resolutionTime ? ( + <> + + +
resolved 
+ {resolvedDate} +
+
+ + ) : null} + + {!resolvedDate && closeTime && ( + + {dayjs().isBefore(closeTime) &&
closes 
} + {!dayjs().isBefore(closeTime) &&
closed 
} + +
+ )} +
+ ) + } else return <> +} + +export function MarketGroups(props: { + contract: Contract + isMobile: boolean | undefined + disabled: boolean | undefined +}) { const [open, setOpen] = useState(false) - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 600 + const user = useUser() + const { contract, isMobile, disabled } = props const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( - - {groupToDisplay.name} +
+ {groupToDisplay.name} +
) : ( - - ) - - return ( - - - - {disabled ? ( - creatorName - ) : ( - +
} - - + > + No Group +
+
+ ) + return ( + <> + {disabled ? ( - groupInfo - ) : !groupToDisplay && !user ? ( -
+ { groupInfo } ) : ( {groupInfo} - {user && groupToDisplay && ( - + + )} )} @@ -201,45 +281,7 @@ export function ContractDetails(props: { - - {(!!closeTime || !!resolvedDate) && ( - - {resolvedDate && resolutionTime ? ( - <> - - - {resolvedDate} - - - ) : null} - - {!resolvedDate && closeTime && user && ( - <> - - - - )} - - )} - {user && ( - <> - - -
{volumeLabel}
-
- {!disabled && ( - - )} - - )} - + ) } @@ -280,12 +322,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( + Closes  - Ends ) )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index ae586725..07958378 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,6 +18,7 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' +import { Button } from '../button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -67,19 +68,21 @@ export function ContractInfoDialog(props: { return ( <> - + - + <Title className="!mt-0 !mb-0" text="This Market" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 1bfe84de..bfb4829f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails, ExtraMobileContractDetails } from './contract-details' +import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> + <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-3 px-2 sm:gap-4"> + <Col className="gap-1 px-2"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -113,10 +112,6 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - forceShowVolume={true} - /> </Col> </Col> ) @@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 5d5ee4d8..af5db9c3 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' -import { withTracking } from 'web/lib/service/analytics' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/challenge' -import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props - const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> + <Row> + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} <Button - size="lg" + size="sm" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Col className={'items-center sm:flex-row'}> - <ShareIcon - className={clsx('h-[24px] w-5 sm:mr-2')} - aria-hidden="true" - /> - <span>Share</span> - </Col> + <Row> + <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> + </Row> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - - {showChallenge && ( - <Button - size="lg" - color="gray-white" - className="max-w-xs self-center" - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <Col className="items-center sm:flex-row"> - <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> - <span>Challenge</span> - </Col> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={setOpenCreateChallengeModal} - user={user} - contract={contract} - /> - </Button> - )} - - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} - <Col className={'justify-center md:hidden'}> + <Col className={'justify-center'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index e35e3e7e..01dce32f 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,15 +38,16 @@ export function LikeMarketButton(props: { return ( <Button - size={'lg'} + size={'sm'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row'}> + <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-[24px] w-5 sm:mr-2', + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -54,7 +55,18 @@ export function LikeMarketButton(props: { : '' )} /> - Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} </Col> </Button> ) diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 09495169..6344757d 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,4 +1,6 @@ +import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -54,18 +56,73 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const currentUser = useUser() - const following = useFollows(currentUser?.id) + const user = useUser() + const following = useFollows(user?.id) const isFollowing = following?.includes(userId) - if (!currentUser || currentUser.id === userId) return null + if (!user || user.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(currentUser.id, userId)} - onUnfollow={() => unfollow(currentUser.id, userId)} + onFollow={() => follow(user.id, userId)} + onUnfollow={() => unfollow(user.id, userId)} small={small} /> ) } + +export function MiniUserFollowButton(props: { userId: string }) { + const { userId } = props + const user = useUser() + const following = useFollows(user?.id) + const isFollowing = following?.includes(userId) + const isFirstRender = useRef(true) + const [justFollowed, setJustFollowed] = useState(false) + + useEffect(() => { + if (isFirstRender.current) { + if (isFollowing != undefined) { + isFirstRender.current = false + } + return + } + if (isFollowing) { + setJustFollowed(true) + setTimeout(() => { + setJustFollowed(false) + }, 1000) + } + }, [isFollowing]) + + if (justFollowed) { + return ( + <CheckCircleIcon + className={clsx( + 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + ) + } + if ( + !user || + user.id === userId || + isFollowing || + !user || + isFollowing === undefined + ) + return null + return ( + <> + <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> + <PlusCircleIcon + className={clsx( + 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + </button> + </> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 1dd261cb..0e65165b 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'lg'} + size={'sm'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,13 +56,19 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Unwatch + <EyeOffIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Watch + <EyeIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Watch */} </Col> )} <WatchMarketModal diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2c011c90..a0b2ed50 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,7 +37,6 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -257,7 +256,6 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> - <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/tailwind.config.js b/web/tailwind.config.js index eb411216..7bea3ec2 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,6 +26,8 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', + 'highlight-blue': '#5BCEFF', + 'hover-blue': '#90DEFF', }, typography: { quoteless: { From 176acf959fe9fe092dfd84fb446062bf916e5734 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 13:55:57 +0100 Subject: [PATCH 29/37] Revert "Inga/bettingfix (#879)" This reverts commit 8aaaf5e9e05e138d4ec8dbf436ebf594d9fa0792. --- web/components/button.tsx | 5 +- web/components/contract/contract-details.tsx | 250 ++++++++---------- .../contract/contract-info-dialog.tsx | 11 +- web/components/contract/contract-overview.tsx | 12 +- .../contract/extra-contract-actions-row.tsx | 57 +++- .../contract/like-market-button.tsx | 20 +- web/components/follow-button.tsx | 67 +---- web/components/follow-market-button.tsx | 16 +- web/pages/[username]/[contractSlug].tsx | 2 + web/tailwind.config.js | 2 - 10 files changed, 181 insertions(+), 261 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index ea9a3e88..cb39cba8 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,7 +11,6 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' - | 'highlight-blue' export function Button(props: { className?: string @@ -57,9 +56,7 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', - color === 'highlight-blue' && - 'text-highlight-blue border-none shadow-none', + 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', className )} disabled={disabled} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index fad62c86..e28ab41a 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,4 +1,9 @@ -import { ClockIcon } from '@heroicons/react/outline' +import { + ClockIcon, + DatabaseIcon, + PencilIcon, + UserGroupIcon, +} from '@heroicons/react/outline' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -11,8 +16,9 @@ import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' +import { ContractInfoDialog } from './contract-info-dialog' import NewContractBadge from '../new-contract-badge' -import { MiniUserFollowButton } from '../follow-button' +import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' @@ -28,8 +34,6 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { useWindowSize } from 'web/hooks/use-window-size' -import { ExtraContractActionsRow } from './extra-contract-actions-row' -import { PlusCircleIcon } from '@heroicons/react/solid' export type ShowTime = 'resolve-date' | 'close-date' @@ -100,174 +104,90 @@ export function AvatarDetails(props: { ) } -export function useIsMobile() { - const { width } = useWindowSize() - return (width ?? 0) < 600 -} - export function ContractDetails(props: { contract: Contract disabled?: boolean }) { const { contract, disabled } = props - const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract - const { resolvedDate } = contractMetrics(contract) + const { + closeTime, + creatorName, + creatorUsername, + creatorId, + creatorAvatarUrl, + resolutionTime, + } = contract + const { volumeLabel, resolvedDate } = contractMetrics(contract) const user = useUser() const isCreator = user?.id === creatorId - const isMobile = useIsMobile() - - return ( - <Col> - <Row> - <Avatar - username={creatorUsername} - avatarUrl={creatorAvatarUrl} - noLink={disabled} - size={9} - className="mr-1.5" - /> - {!disabled && ( - <div className="absolute mt-3 ml-[11px]"> - <MiniUserFollowButton userId={creatorId} /> - </div> - )} - <Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm"> - <Row className="w-full justify-between "> - {disabled ? ( - creatorName - ) : ( - <UserLink - className="my-auto whitespace-nowrap" - name={creatorName} - username={creatorUsername} - short={isMobile} - /> - )} - </Row> - <Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs"> - <CloseOrResolveTime - contract={contract} - resolvedDate={resolvedDate} - isCreator={isCreator} - /> - {!isMobile && ( - <MarketGroups - contract={contract} - isMobile={isMobile} - disabled={disabled} - /> - )} - </Row> - </Col> - <div className="mt-0"> - <ExtraContractActionsRow contract={contract} /> - </div> - </Row> - {/* GROUPS */} - {isMobile && ( - <div className="mt-2"> - <MarketGroups - contract={contract} - isMobile={isMobile} - disabled={disabled} - /> - </div> - )} - </Col> - ) -} - -export function CloseOrResolveTime(props: { - contract: Contract - resolvedDate: any - isCreator: boolean -}) { - const { contract, resolvedDate, isCreator } = props - const { resolutionTime, closeTime } = contract - console.log(closeTime, resolvedDate) - if (!!closeTime || !!resolvedDate) { - return ( - <Row className="select-none items-center gap-1"> - {resolvedDate && resolutionTime ? ( - <> - <DateTimeTooltip text="Market resolved:" time={resolutionTime}> - <Row> - <div>resolved </div> - {resolvedDate} - </Row> - </DateTimeTooltip> - </> - ) : null} - - {!resolvedDate && closeTime && ( - <Row> - {dayjs().isBefore(closeTime) && <div>closes </div>} - {!dayjs().isBefore(closeTime) && <div>closed </div>} - <EditableCloseDate - closeTime={closeTime} - contract={contract} - isCreator={isCreator ?? false} - /> - </Row> - )} - </Row> - ) - } else return <></> -} - -export function MarketGroups(props: { - contract: Contract - isMobile: boolean | undefined - disabled: boolean | undefined -}) { const [open, setOpen] = useState(false) - const user = useUser() - const { contract, isMobile, disabled } = props + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> <a className={clsx( - 'flex flex-row items-center truncate pr-1', + linkClass, + 'flex flex-row items-center truncate pr-0 sm:pr-2', isMobile ? 'max-w-[140px]' : 'max-w-[250px]' )} > - <div className="bg-greyscale-4 hover:bg-greyscale-3 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs"> - {groupToDisplay.name} - </div> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="items-center truncate">{groupToDisplay.name}</span> </a> </Link> ) : ( - <Row - className={clsx( - 'cursor-default select-none items-center truncate pr-1', - isMobile ? 'max-w-[140px]' : 'max-w-[250px]' - )} + <Button + size={'xs'} + className={'max-w-[200px] pr-2'} + color={'gray-white'} + onClick={() => !groupToDisplay && setOpen(true)} > - <div - className={clsx( - 'bg-greyscale-4 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs' - )} - > - No Group - </div> - </Row> + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="truncate">No Group</span> + </Row> + </Button> ) + return ( - <> - <Row className="align-middle"> + <Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2"> + <Row className="items-center gap-2"> + <Avatar + username={creatorUsername} + avatarUrl={creatorAvatarUrl} + noLink={disabled} + size={6} + /> {disabled ? ( - { groupInfo } + creatorName + ) : ( + <UserLink + className="whitespace-nowrap" + name={creatorName} + username={creatorUsername} + short={isMobile} + /> + )} + {!disabled && <UserFollowButton userId={creatorId} small />} + </Row> + <Row> + {disabled ? ( + groupInfo + ) : !groupToDisplay && !user ? ( + <div /> ) : ( <Row> {groupInfo} - {user && ( - <button - className="text-greyscale-4 hover:text-greyscale-3" + {user && groupToDisplay && ( + <Button + size={'xs'} + color={'gray-white'} onClick={() => setOpen(!open)} > - <PlusCircleIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> - </button> + <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> + </Button> )} </Row> )} @@ -281,7 +201,45 @@ export function MarketGroups(props: { <ContractGroupsList contract={contract} user={user} /> </Col> </Modal> - </> + + {(!!closeTime || !!resolvedDate) && ( + <Row className="hidden items-center gap-1 md:inline-flex"> + {resolvedDate && resolutionTime ? ( + <> + <ClockIcon className="h-5 w-5" /> + <DateTimeTooltip text="Market resolved:" time={resolutionTime}> + {resolvedDate} + </DateTimeTooltip> + </> + ) : null} + + {!resolvedDate && closeTime && user && ( + <> + <ClockIcon className="h-5 w-5" /> + <EditableCloseDate + closeTime={closeTime} + contract={contract} + isCreator={isCreator ?? false} + /> + </> + )} + </Row> + )} + {user && ( + <> + <Row className="hidden items-center gap-1 md:inline-flex"> + <DatabaseIcon className="h-5 w-5" /> + <div className="whitespace-nowrap">{volumeLabel}</div> + </Row> + {!disabled && ( + <ContractInfoDialog + contract={contract} + className={'hidden md:inline-flex'} + /> + )} + </> + )} + </Row> ) } @@ -322,12 +280,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( <Col className={'items-center text-sm text-gray-500'}> - <Row className={'text-gray-400'}>Closes </Row> <EditableCloseDate closeTime={closeTime} contract={contract} isCreator={creatorId === user?.id} /> + <Row className={'text-gray-400'}>Ends</Row> </Col> ) )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 07958378..ae586725 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,7 +18,6 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' -import { Button } from '../button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -68,21 +67,19 @@ export function ContractInfoDialog(props: { return ( <> - <Button - size="sm" - color="gray-white" + <button className={clsx(contractDetailsButtonClassName, className)} onClick={() => setOpen(true)} > <DotsHorizontalIcon - className={clsx('h-5 w-5 flex-shrink-0')} + className={clsx('h-6 w-6 flex-shrink-0')} aria-hidden="true" /> - </Button> + </button> <Modal open={open} setOpen={setOpen}> <Col className="gap-4 rounded bg-white p-6"> - <Title className="!mt-0 !mb-0" text="This Market" /> + <Title className="!mt-0 !mb-0" text="Market info" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bfb4829f..1bfe84de 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails } from './contract-details' +import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> + <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-1 px-2"> + <Col className="gap-3 px-2 sm:gap-4"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,6 +85,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -112,6 +113,10 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ExtraMobileContractDetails + contract={contract} + forceShowVolume={true} + /> </Col> </Col> ) @@ -135,6 +140,7 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index af5db9c3..5d5ee4d8 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,29 +11,38 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' +import { withTracking } from 'web/lib/service/analytics' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { CHALLENGES_ENABLED } from 'common/challenge' +import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props + const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row> - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} + <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> <Button - size="sm" + size="lg" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Row> - <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> - </Row> + <Col className={'items-center sm:flex-row'}> + <ShareIcon + className={clsx('h-[24px] w-5 sm:mr-2')} + aria-hidden="true" + /> + <span>Share</span> + </Col> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -41,7 +50,35 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - <Col className={'justify-center'}> + + {showChallenge && ( + <Button + size="lg" + color="gray-white" + className="max-w-xs self-center" + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <Col className="items-center sm:flex-row"> + <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> + <span>Challenge</span> + </Col> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={setOpenCreateChallengeModal} + user={user} + contract={contract} + /> + </Button> + )} + + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} + <Col className={'justify-center md:hidden'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 01dce32f..e35e3e7e 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,16 +38,15 @@ export function LikeMarketButton(props: { return ( <Button - size={'sm'} + size={'lg'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'relative items-center sm:flex-row'}> + <Col className={'items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-5 w-5 sm:h-6 sm:w-6', - totalTipped > 0 ? 'mr-2' : '', + 'h-[24px] w-5 sm:mr-2', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -55,18 +54,7 @@ export function LikeMarketButton(props: { : '' )} /> - {totalTipped > 0 && ( - <div - className={clsx( - 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', - totalTipped > 99 - ? 'text-[0.4rem] sm:text-[0.5rem]' - : 'sm:text-2xs text-[0.5rem]' - )} - > - {totalTipped} - </div> - )} + Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} </Col> </Button> ) diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 6344757d..09495169 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,6 +1,4 @@ -import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' -import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -56,73 +54,18 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const user = useUser() - const following = useFollows(user?.id) + const currentUser = useUser() + const following = useFollows(currentUser?.id) const isFollowing = following?.includes(userId) - if (!user || user.id === userId) return null + if (!currentUser || currentUser.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(user.id, userId)} - onUnfollow={() => unfollow(user.id, userId)} + onFollow={() => follow(currentUser.id, userId)} + onUnfollow={() => unfollow(currentUser.id, userId)} small={small} /> ) } - -export function MiniUserFollowButton(props: { userId: string }) { - const { userId } = props - const user = useUser() - const following = useFollows(user?.id) - const isFollowing = following?.includes(userId) - const isFirstRender = useRef(true) - const [justFollowed, setJustFollowed] = useState(false) - - useEffect(() => { - if (isFirstRender.current) { - if (isFollowing != undefined) { - isFirstRender.current = false - } - return - } - if (isFollowing) { - setJustFollowed(true) - setTimeout(() => { - setJustFollowed(false) - }, 1000) - } - }, [isFollowing]) - - if (justFollowed) { - return ( - <CheckCircleIcon - className={clsx( - 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' - )} - aria-hidden="true" - /> - ) - } - if ( - !user || - user.id === userId || - isFollowing || - !user || - isFollowing === undefined - ) - return null - return ( - <> - <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> - <PlusCircleIcon - className={clsx( - 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' - )} - aria-hidden="true" - /> - </button> - </> - ) -} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 0e65165b..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'sm'} + size={'lg'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,19 +56,13 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> - {/* Unwatch */} + <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Unwatch </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> - {/* Watch */} + <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Watch </Col> )} <WatchMarketModal diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index a0b2ed50..2c011c90 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,6 +37,7 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' +import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -256,6 +257,7 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> + <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 7bea3ec2..eb411216 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,8 +26,6 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', - 'highlight-blue': '#5BCEFF', - 'hover-blue': '#90DEFF', }, typography: { quoteless: { From e5428ce52540e0b31eb67bd548fa038d00ca5fcd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:14:59 -0600 Subject: [PATCH 30/37] Watch market modal copy --- web/components/contract/watch-market-modal.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/components/contract/watch-market-modal.tsx b/web/components/contract/watch-market-modal.tsx index 2fb9bc00..8f79e1ed 100644 --- a/web/components/contract/watch-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -18,21 +18,22 @@ export const WatchMarketModal = (props: { <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What is watching?</span> <span className={'ml-2'}> - You'll receive notifications on markets by betting, commenting, or - clicking the + Watching a market means you'll receive notifications from activity + on it. You automatically start watching a market if you comment on + it, bet on it, or click the <EyeIcon className={clsx('ml-1 inline h-6 w-6 align-top')} aria-hidden="true" /> - ️ button on them. + ️ button. </span> <span className={'text-indigo-700'}> • What types of notifications will I receive? </span> <span className={'ml-2'}> - 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. + 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> From 4a5c6a42f67fdd1610bf26190837ef9e7c560e15 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:45:11 -0600 Subject: [PATCH 31/37] Store bonus txn data in data field --- common/txn.ts | 29 ++++++++++++++-- functions/src/on-create-bet.ts | 4 ++- .../scripts/update-bonus-txn-data-fields.ts | 34 +++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 functions/src/scripts/update-bonus-txn-data-fields.ts diff --git a/common/txn.ts b/common/txn.ts index 00b19570..713d4a38 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,12 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus +type AnyTxnType = + | Donation + | Tip + | Manalink + | Referral + | UniqueBettorBonus + | BettingStreakBonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -60,10 +66,27 @@ type Referral = { category: 'REFERRAL' } -type Bonus = { +type UniqueBettorBonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' + category: 'UNIQUE_BETTOR_BONUS' + // This data was mistakenly stored as a stringified JSON object in description previously + data: { + contractId: string + uniqueNewBettorId?: string + // Previously stored all unique bettor ids in description + uniqueBettorIds?: string[] + } +} + +type BettingStreakBonus = { + fromType: 'BANK' + toType: 'USER' + category: 'BETTING_STREAK_BONUS' + // This data was mistakenly stored as a stringified JSON object in description previously + data: { + currentBettingStreak?: number + } } export type DonationTxn = Txn & Donation diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5fe3fd62..7f4ca067 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -119,6 +119,7 @@ const updateBettingStreak = async ( token: 'M$', category: 'BETTING_STREAK_BONUS', description: JSON.stringify(bonusTxnDetails), + data: bonusTxnDetails, } return await runTxn(trans, bonusTxn) }) @@ -186,7 +187,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { contractId: contract.id, - uniqueBettorIds: newUniqueBettorIds, + uniqueNewBettorId: bettor.id, } const fromUserId = isProd() ? HOUSE_LIQUIDITY_PROVIDER_ID @@ -204,6 +205,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( token: 'M$', category: 'UNIQUE_BETTOR_BONUS', description: JSON.stringify(bonusTxnDetails), + data: bonusTxnDetails, } return await runTxn(trans, bonusTxn) }) diff --git a/functions/src/scripts/update-bonus-txn-data-fields.ts b/functions/src/scripts/update-bonus-txn-data-fields.ts new file mode 100644 index 00000000..82955fa0 --- /dev/null +++ b/functions/src/scripts/update-bonus-txn-data-fields.ts @@ -0,0 +1,34 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { Txn } from 'common/txn' +import { getValues } from 'functions/src/utils' + +initAdmin() + +const firestore = admin.firestore() + +async function main() { + // get all txns + const bonusTxns = await getValues<Txn>( + firestore + .collection('txns') + .where('category', 'in', ['UNIQUE_BETTOR_BONUS', 'BETTING_STREAK_BONUS']) + ) + // JSON parse description field and add to data field + const updatedTxns = bonusTxns.map((txn) => { + txn.data = txn.description && JSON.parse(txn.description) + return txn + }) + console.log('updatedTxns', updatedTxns[0]) + // update txns + await Promise.all( + updatedTxns.map((txn) => { + return firestore.collection('txns').doc(txn.id).update({ + data: txn.data, + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) From 733d2065178aec2e7de32e658c194d2b04e26bbe Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:50:35 -0600 Subject: [PATCH 32/37] Add txn types --- common/txn.ts | 2 ++ functions/src/on-create-bet.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/common/txn.ts b/common/txn.ts index 713d4a38..ac3b76de 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -93,3 +93,5 @@ export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink export type ReferralTxn = Txn & Referral +export type BettingStreakBonusTxn = Txn & BettingStreakBonus +export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 7f4ca067..b645e3b7 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -27,6 +27,7 @@ import { User } from '../../common/user' import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' import { addHouseLiquidity } from './add-liquidity' import { DAY_MS } from '../../common/util/time' +import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -109,6 +110,7 @@ const updateBettingStreak = async ( const bonusTxnDetails = { currentBettingStreak: newBettingStreak, } + // TODO: set the id of the txn to the eventId to prevent duplicates const result = await firestore.runTransaction(async (trans) => { const bonusTxn: TxnData = { fromId: fromUserId, @@ -120,7 +122,7 @@ const updateBettingStreak = async ( category: 'BETTING_STREAK_BONUS', description: JSON.stringify(bonusTxnDetails), data: bonusTxnDetails, - } + } as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'> return await runTxn(trans, bonusTxn) }) if (!result.txn) { @@ -195,6 +197,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( const fromSnap = await firestore.doc(`users/${fromUserId}`).get() if (!fromSnap.exists) throw new APIError(400, 'From user not found.') const fromUser = fromSnap.data() as User + // TODO: set the id of the txn to the eventId to prevent duplicates const result = await firestore.runTransaction(async (trans) => { const bonusTxn: TxnData = { fromId: fromUser.id, @@ -206,7 +209,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( category: 'UNIQUE_BETTOR_BONUS', description: JSON.stringify(bonusTxnDetails), data: bonusTxnDetails, - } + } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> return await runTxn(trans, bonusTxn) }) From ada9fac343de92313e451e79a823d9319089f55f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 08:07:42 -0600 Subject: [PATCH 33/37] Add logs to on-create-bet --- functions/src/on-create-bet.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index b645e3b7..ce75f0fe 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -127,6 +127,8 @@ const updateBettingStreak = async ( }) if (!result.txn) { log("betting streak bonus txn couldn't be made") + log('status:', result.status) + log('message:', result.message) return } @@ -214,7 +216,8 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( }) if (result.status != 'success' || !result.txn) { - log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) + log(`No bonus for user: ${contract.creatorId} - status:`, result.status) + log('message:', result.message) } else { log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) await createUniqueBettorBonusNotification( From 772eeb5c93de41b7b2b31aaff121f4918e3ee30a Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 15:45:49 +0100 Subject: [PATCH 34/37] Update [contractSlug].tsx --- web/pages/embed/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index c5fba0c8..fbeef88f 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -116,7 +116,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { tradingAllowed(contract) && !betPanelOpen && ( <Button color="gradient" onClick={() => setBetPanelOpen(true)}> - Bet + Predict </Button> )} From 718218c717c1093f7e4706e4bd35badcf171ec65 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 15:51:14 +0100 Subject: [PATCH 35/37] Update bet-inline.tsx --- web/components/bet-inline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index af75ff7c..a8f4d718 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -79,7 +79,7 @@ export function BetInline(props: { return ( <Col className={clsx('items-center', className)}> <Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3"> - <div className="text-xl">Bet</div> + <div className="text-xl">Predict</div> <YesNoSelector className="space-x-0" btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl" From 4c10c8499b51c4e8a253a7230e8961f2dd03ed96 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:12:44 -0600 Subject: [PATCH 36/37] Take back unique bettor bonuses on N/A --- functions/src/resolve-market.ts | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 44293898..b99b5c87 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -9,7 +9,7 @@ import { RESOLUTIONS, } from '../../common/contract' import { Bet } from '../../common/bet' -import { getUser, isProd, payUser } from './utils' +import { getUser, getValues, isProd, log, payUser } from './utils' import { getLoanPayouts, getPayouts, @@ -22,6 +22,12 @@ import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn' +import { runTxn, TxnData } from './transact' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' const bodySchema = z.object({ contractId: z.string(), @@ -163,6 +169,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { await processPayouts(liquidityPayouts, true) await processPayouts([...payouts, ...loanPayouts]) + await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) @@ -299,4 +306,55 @@ function validateAnswer( } } +async function undoUniqueBettorRewardsIfCancelResolution( + contract: Contract, + outcome: string +) { + if (outcome === 'CANCEL') { + const creatorsBonusTxns = await getValues<Txn>( + firestore + .collection('txns') + .where('category', '==', 'UNIQUE_BETTOR_BONUS') + .where('toId', '==', contract.creatorId) + ) + + const bonusTxnsOnThisContract = creatorsBonusTxns.filter( + (txn) => txn.data && txn.data.contractId === contract.id + ) + log('total bonusTxnsOnThisContract', bonusTxnsOnThisContract.length) + const totalBonusAmount = sumBy(bonusTxnsOnThisContract, (txn) => txn.amount) + log('totalBonusAmount to be withdrawn', totalBonusAmount) + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: contract.creatorId, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount: totalBonusAmount, + token: 'M$', + category: 'CANCEL_UNIQUE_BETTOR_BONUS', + data: { + contractId: contract.id, + }, + } as Omit<CancelUniqueBettorBonusTxn, 'id' | 'createdTime'> + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't cancel bonus for user: ${contract.creatorId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Cancel Bonus txn for user: ${contract.creatorId} completed:`, + result.txn?.id + ) + } + } +} + const firestore = admin.firestore() From e9f136a653b6fc6a17a76ebbdad3d9a4e0ea4fb0 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:12:56 -0600 Subject: [PATCH 37/37] Single source of truth for predict --- common/envs/prod.ts | 6 ++++++ common/txn.ts | 12 ++++++++++++ common/user.ts | 8 ++++++++ common/util/format.ts | 4 ++++ web/components/bet-button.tsx | 5 +++-- web/components/contract-search.tsx | 6 +++--- web/components/contract/contract-info-dialog.tsx | 3 ++- web/components/contract/contract-leaderboard.tsx | 3 ++- web/components/contract/contract-tabs.tsx | 6 +++--- web/components/feed/feed-bets.tsx | 3 ++- web/components/feed/feed-comments.tsx | 4 ++-- web/components/feed/feed-liquidity.tsx | 4 ++-- web/components/liquidity-panel.tsx | 5 ++++- web/components/nav/nav-bar.tsx | 3 ++- web/components/nav/profile-menu.tsx | 3 ++- web/components/numeric-resolution-panel.tsx | 8 ++++++-- web/components/profile/loans-modal.tsx | 3 ++- web/components/resolution-panel.tsx | 16 +++++++++++----- web/components/user-page.tsx | 5 +++-- web/pages/contract-search-firestore.tsx | 3 ++- web/pages/group/[...slugs]/index.tsx | 3 ++- web/pages/leaderboards.tsx | 5 +++-- web/pages/stats.tsx | 3 ++- 23 files changed, 88 insertions(+), 33 deletions(-) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 6bf781b7..a9d1ffc3 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -15,6 +15,9 @@ export type EnvConfig = { // Branding moneyMoniker: string // e.g. 'M$' + bettor?: string // e.g. 'bettor' or 'predictor' + presentBet?: string // e.g. 'bet' or 'predict' + pastBet?: string // e.g. 'bet' or 'prediction' faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] @@ -79,6 +82,9 @@ export const PROD_CONFIG: EnvConfig = { visibility: 'PUBLIC', moneyMoniker: 'M$', + bettor: 'predictor', + pastBet: 'prediction', + presentBet: 'predict', navbarLogoPath: '', faviconPath: '/favicon.ico', newQuestionPlaceholders: [ diff --git a/common/txn.ts b/common/txn.ts index ac3b76de..9c83761f 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -7,6 +7,7 @@ type AnyTxnType = | Referral | UniqueBettorBonus | BettingStreakBonus + | CancelUniqueBettorBonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -29,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' + | 'CANCEL_UNIQUE_BETTOR_BONUS' // Any extra data data?: { [key: string]: any } @@ -89,9 +91,19 @@ type BettingStreakBonus = { } } +type CancelUniqueBettorBonus = { + fromType: 'USER' + toType: 'BANK' + category: 'CANCEL_UNIQUE_BETTOR_BONUS' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus +export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus diff --git a/common/user.ts b/common/user.ts index 16a2b437..b490ab0c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,4 +1,5 @@ import { notification_preferences } from './user-notification-preferences' +import { ENV_CONFIG } from 'common/envs/constants' export type User = { id: string @@ -83,3 +84,10 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' + +export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor +export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors' +export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict +export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets' +export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction +export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..9b9ee1df 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -16,6 +16,10 @@ export function formatMoneyWithDecimals(amount: number) { return ENV_CONFIG.moneyMoniker + amount.toFixed(2) } +export function capitalFirst(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1) +} + export function formatWithCommas(amount: number) { return formatter.format(Math.floor(amount)).replace('$', '') } diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 0bd3702f..c0177fb3 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' import { BetSignUpPrompt } from './sign-up-prompt' +import { PRESENT_BET } from 'common/user' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -36,12 +37,12 @@ export default function BetButton(props: { <Button size="lg" className={clsx( - 'my-auto inline-flex min-w-[75px] whitespace-nowrap', + 'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize', btnClassName )} onClick={() => setOpen(true)} > - Predict + {PRESENT_BET} </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 7f64b26b..a5e86545 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' import { useRouter } from 'next/router' import { Contract } from 'common/contract' -import { User } from 'common/user' +import { PAST_BETS, User } from 'common/user' import { ContractHighlightOptions, ContractsGrid, @@ -41,7 +41,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' export const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, - { label: 'Most traded', value: 'most-traded' }, + { label: `Most ${PAST_BETS}`, value: 'most-traded' }, { label: '24h volume', value: '24-hour-vol' }, { label: '24h change', value: 'prob-change-day' }, { label: 'Last updated', value: 'last-updated' }, @@ -450,7 +450,7 @@ function ContractSearchControls(props: { selected={state.pillFilter === 'your-bets'} onSelect={selectPill('your-bets')} > - Your trades + Your {PAST_BETS} </PillButton> )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index ae586725..9027d38a 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,6 +18,7 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' +import { BETTORS } from 'common/user' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -135,7 +136,7 @@ export function ContractInfoDialog(props: { </tr> */} <tr> - <td>Traders</td> + <td>{BETTORS}</td> <td>{bettorsCount}</td> </tr> diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 54b2c79e..fec6744d 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -12,6 +12,7 @@ import { FeedComment } from '../feed/feed-comments' import { Spacer } from '../layout/spacer' import { Leaderboard } from '../leaderboard' import { Title } from '../title' +import { BETTORS } from 'common/user' export function ContractLeaderboard(props: { contract: Contract @@ -48,7 +49,7 @@ export function ContractLeaderboard(props: { return users && users.length > 0 ? ( <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={users || []} columns={[ { diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index d63d3963..5b88e005 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,7 +1,7 @@ import { Bet } from 'common/bet' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractComment } from 'common/comment' -import { User } from 'common/user' +import { PAST_BETS, User } from 'common/user' import { ContractCommentsActivity, ContractBetsActivity, @@ -114,13 +114,13 @@ export function ContractTabs(props: { badge: `${comments.length}`, }, { - title: 'Trades', + title: PAST_BETS, content: betActivity, badge: `${visibleBets.length}`, }, ...(!user || !userBets?.length ? [] - : [{ title: 'Your trades', content: yourTrades }]), + : [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), ]} /> {!user ? ( diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index def97801..b2852739 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -14,6 +14,7 @@ import { SiteLink } from 'web/components/site-link' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { Challenge } from 'common/challenge' import { UserLink } from 'web/components/user-link' +import { BETTOR } from 'common/user' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props @@ -94,7 +95,7 @@ export function BetStatusText(props: { {!hideUser ? ( <UserLink name={bet.userName} username={bet.userUsername} /> ) : ( - <span>{self?.id === bet.userId ? 'You' : 'A trader'}</span> + <span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} {money} {outOfTotalAmount} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f896ddb5..9d2ba85e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -1,6 +1,6 @@ import { Bet } from 'common/bet' import { ContractComment } from 'common/comment' -import { User } from 'common/user' +import { PRESENT_BET, User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' @@ -255,7 +255,7 @@ function CommentStatus(props: { const { contract, outcome, prob } = props return ( <> - {' betting '} + {` ${PRESENT_BET}ing `} <OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> {prob && ' at ' + Math.round(prob * 100) + '%'} </> diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 8f8faf9b..181eb4b7 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import dayjs from 'dayjs' -import { User } from 'common/user' +import { BETTOR, User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -74,7 +74,7 @@ export function LiquidityStatusText(props: { {bettor ? ( <UserLink name={bettor.name} username={bettor.username} /> ) : ( - <span>{isSelf ? 'You' : 'A trader'}</span> + <span>{isSelf ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} a subsidy of {money} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 0474abf7..58f57a8a 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' import { track } from 'web/lib/service/analytics' import { InfoTooltip } from './info-tooltip' +import { BETTORS, PRESENT_BET } from 'common/user' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { <> <div className="mb-4 text-gray-500"> Contribute your M$ to make this market more accurate.{' '} - <InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." /> + <InfoTooltip + text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`} + /> </div> <Row> diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 242d6ff5..a07fa0ad 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -17,6 +17,7 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' +import { PAST_BETS } from 'common/user' function getNavigation() { return [ @@ -64,7 +65,7 @@ export function BottomNavBar() { item={{ name: formatMoney(user.balance), trackingEventName: 'profile', - href: `/${user.username}?tab=trades`, + href: `/${user.username}?tab=${PAST_BETS}`, icon: () => ( <Avatar className="mx-auto my-1" diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index e7cc056f..cf91ac66 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' +import { PAST_BETS } from 'common/user' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}?tab=trades`}> + <Link href={`/${user.username}?tab=${PAST_BETS}`}> <a onClick={trackCallback('sidebar: profile')} className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index 70fbf01f..0220f7a7 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -10,6 +10,7 @@ import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api' import { BucketInput } from './bucket-input' import { getPseudoProbability } from 'common/pseudo-numeric' +import { BETTOR, BETTORS, PAST_BETS } from 'common/user' export function NumericResolutionPanel(props: { isAdmin: boolean @@ -111,9 +112,12 @@ export function NumericResolutionPanel(props: { <div> {outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 24b23e5b..5dcb8b6b 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -1,5 +1,6 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' +import { PAST_BETS } from 'common/user' export function LoansModal(props: { isOpen: boolean @@ -11,7 +12,7 @@ export function LoansModal(props: { <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <span className={'text-8xl'}>🏦</span> - <span className="text-xl">Daily loans on your trades</span> + <span className="text-xl">Daily loans on your {PAST_BETS}</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 6f36331e..7ef6e4f3 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -10,6 +10,7 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api' import { ProbabilitySelector } from './probability-selector' import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' +import { BETTOR, BETTORS, PAST_BETS } from 'common/user' export function ResolutionPanel(props: { isAdmin: boolean @@ -90,23 +91,28 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to traders who bought YES. + Winnings will be paid out to {BETTORS} who bought YES. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> - Winnings will be paid out to traders who bought NO. + Winnings will be paid out to {BETTORS} who bought NO. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : outcome === 'MKT' ? ( <Col className="gap-6"> - <div>Traders will be paid out at the probability you specify:</div> + <div> + {PAST_BETS} will be paid out at the probability you specify: + </div> <ProbabilitySelector probabilityInt={Math.round(prob)} setProbabilityInt={setProb} @@ -114,7 +120,7 @@ export function ResolutionPanel(props: { {/* You will earn {earnedFees}. */} </Col> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 5485267c..9dfd3491 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -25,7 +25,7 @@ import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { ReferralsButton } from 'web/components/referrals-button' -import { formatMoney } from 'common/util/format' +import { capitalFirst, formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' import { @@ -35,6 +35,7 @@ import { import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' +import { PAST_BETS } from 'common/user' export function UserPage(props: { user: User }) { const { user } = props @@ -269,7 +270,7 @@ export function UserPage(props: { user: User }) { ), }, { - title: 'Trades', + title: capitalFirst(PAST_BETS), content: ( <> <BetsList user={user} /> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 4691030c..4d6ada1d 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -8,6 +8,7 @@ import { usePersistentState, urlParamStore, } from 'web/hooks/use-persistent-state' +import { PAST_BETS } from 'common/user' const MAX_CONTRACTS_RENDERED = 100 @@ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: { > <option value="score">Trending</option> <option value="newest">Newest</option> - <option value="most-traded">Most traded</option> + <option value="most-traded">Most ${PAST_BETS}</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> </select> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f1521b42..70b06ac5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -50,6 +50,7 @@ import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' import { SelectMarketsModal } from 'web/components/contract-select-modal' +import { BETTORS } from 'common/user' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -155,7 +156,7 @@ export default function GroupPage(props: { <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <GroupLeaderboard topUsers={topTraders} - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} header="Profit" maxToShow={maxLeaderboardSize} /> diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 08819833..4f1e9437 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -14,6 +14,7 @@ import { Title } from 'web/components/title' import { Tabs } from 'web/components/layout/tabs' import { useTracking } from 'web/hooks/use-tracking' import { SEO } from 'web/components/SEO' +import { BETTORS } from 'common/user' export async function getStaticProps() { const props = await fetchProps() @@ -79,7 +80,7 @@ export default function Leaderboards(_props: { <> <Col className="mx-4 items-center gap-10 lg:flex-row"> <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={topTraders} columns={[ { @@ -126,7 +127,7 @@ export default function Leaderboards(_props: { <Page> <SEO title="Leaderboards" - description="Manifold's leaderboards show the top traders and market creators." + description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`} url="/leaderboards" /> <Title text={'Leaderboards'} className={'hidden md:block'} /> diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index bca0525a..057d47ef 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -13,6 +13,7 @@ import { SiteLink } from 'web/components/site-link' import { Linkify } from 'web/components/linkify' import { getStats } from 'web/lib/firebase/stats' import { Stats } from 'common/stats' +import { PAST_BETS } from 'common/user' export default function Analytics() { const [stats, setStats] = useState<Stats | undefined>(undefined) @@ -156,7 +157,7 @@ export function CustomAnalytics(props: { defaultIndex={0} tabs={[ { - title: 'Trades', + title: PAST_BETS, content: ( <DailyCountChart dailyCounts={dailyBetCounts}