From 7628713c4b43a99992b9184be6346d0b21df8b39 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 15 Sep 2022 15:25:19 -0600 Subject: [PATCH 1/8] Enrich contract resolved notification --- common/follow.ts | 5 + common/notification.ts | 6 + common/user-notification-preferences.ts | 1 + functions/src/create-notification.ts | 168 +++++++++++++++++++----- functions/src/resolve-market.ts | 32 +---- functions/src/utils.ts | 2 +- web/pages/notifications.tsx | 117 +++++++++++++---- 7 files changed, 243 insertions(+), 88 deletions(-) diff --git a/common/follow.ts b/common/follow.ts index 04ca6899..7ff6e7f2 100644 --- a/common/follow.ts +++ b/common/follow.ts @@ -2,3 +2,8 @@ export type Follow = { userId: string timestamp: number } + +export type ContractFollow = { + id: string // user id + createdTime: number +} diff --git a/common/notification.ts b/common/notification.ts index 2f03467d..804ec68e 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -248,3 +248,9 @@ export type BetFillData = { probability: number fillAmount: number } + +export type ContractResolutionData = { + outcome: string + userPayout: number + userInvestment: number +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index e2402ea9..f585f373 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -218,6 +218,7 @@ const notificationReasonToSubscriptionType: Partial< export const getNotificationDestinationsForUser = ( privateUser: PrivateUser, + // TODO: accept reasons array from most to least important and work backwards reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 390a8cd8..ebd3f26c 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' import { BetFillData, BettingStreakData, + ContractResolutionData, Notification, notification_reason_types, } from '../../common/notification' @@ -28,6 +29,7 @@ import { } from './emails' import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' +import { ContractFollow } from '../../common/follow' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -159,7 +161,7 @@ export type replied_users_info = { export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, sourceType: 'comment' | 'answer' | 'contract', - sourceUpdateType: 'created' | 'updated' | 'resolved', + sourceUpdateType: 'created' | 'updated', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -167,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( miscData?: { repliedUsersInfo: replied_users_info taggedUserIds: string[] - }, - resolutionData?: { - bets: Bet[] - userInvestments: { [userId: string]: number } - userPayouts: { [userId: string]: number } - creator: User - creatorPayout: number - contract: Contract - outcome: string - resolutionProbability?: number - resolutions?: { [outcome: string]: number } } ) => { const { repliedUsersInfo, taggedUserIds } = miscData ?? {} @@ -230,11 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( userId: string, reason: notification_reason_types ) => { - if ( - !stillFollowingContract(sourceContract.creatorId) || - sourceUser.id == userId - ) - return + if (!stillFollowingContract(userId) || sourceUser.id == userId) return const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( @@ -276,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceUser.avatarUrl ) emailRecipientIdsList.push(userId) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'resolved' && - resolutionData - ) { - await sendMarketResolutionEmail( - reason, - privateUser, - resolutionData.userInvestments[userId] ?? 0, - resolutionData.userPayouts[userId] ?? 0, - sourceUser, - resolutionData.creatorPayout, - sourceContract, - resolutionData.outcome, - resolutionData.resolutionProbability, - resolutionData.resolutions - ) - emailRecipientIdsList.push(userId) } } @@ -447,6 +416,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } + //TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they + // have enabled so they will unsubscribe from the least important notifications await notifyRepliedUser() await notifyTaggedUsers() await notifyContractCreator() @@ -943,3 +914,130 @@ export const createNewContractNotification = async ( await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') } } + +export const createContractResolvedNotifications = async ( + contract: Contract, + creator: User, + outcome: string, + probabilityInt: number | undefined, + resolutionValue: number | undefined, + resolutionData: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } + } +) => { + let resolutionText = outcome ?? contract.question + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && resolutionValue) + resolutionText = `${resolutionValue}` + } + + const idempotencyKey = contract.id + '-resolved' + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'resolved', + sourceContractId: contract.id, + sourceUserName: creator.name, + sourceUserUsername: creator.username, + sourceUserAvatarUrl: creator.avatarUrl, + sourceText: resolutionText, + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + sourceTitle: contract.question, + data: { + outcome, + userInvestment: resolutionData.userInvestments[userId] ?? 0, + userPayout: resolutionData.userPayouts[userId] ?? 0, + } as ContractResolutionData, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } + + const sendNotificationsIfSettingsPermit = async ( + userId: string, + reason: notification_reason_types + ) => { + if (!stillFollowingContract(userId) || creator.id == userId) return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( + privateUser, + reason + ) + + // Browser notifications + if (sendToBrowser) { + await createBrowserNotification(userId, reason) + } + + // Emails notifications + if (sendToEmail) + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + creator, + resolutionData.creatorPayout, + contract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions + ) + } + + const contractFollowersIds = ( + await getValues( + firestore.collection(`contracts/${contract.id}/follows`) + ) + ).map((follow) => follow.id) + + const stillFollowingContract = (userId: string) => { + return contractFollowersIds.includes(userId) + } + + await Promise.all( + contractFollowersIds.map((id) => + sendNotificationsIfSettingsPermit( + id, + resolutionData.userInvestments[id] + ? 'resolution_on_contract_with_users_shares_in' + : 'resolution_on_contract_you_follow' + ) + ) + ) +} diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b99b5c87..feddd67c 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -21,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { createContractResolvedNotifications } from './create-notification' import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn' import { runTxn, TxnData } from './transact' import { @@ -177,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { groupBy(bets, (bet) => bet.userId), (bets) => getContractBetMetrics(contract, bets).invested ) - let resolutionText = outcome ?? contract.question - if ( - contract.outcomeType === 'FREE_RESPONSE' || - contract.outcomeType === 'MULTIPLE_CHOICE' - ) { - const answerText = contract.answers.find( - (answer) => answer.id === outcome - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && probabilityInt) - resolutionText = `${probabilityInt}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && value) resolutionText = `${value}` - } - // TODO: this actually may be too slow to complete with a ton of users to notify? - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - creator, - contract.id + '-resolution', - resolutionText, + await createContractResolvedNotifications( contract, - undefined, + creator, + outcome, + probabilityInt, + value, { bets, userInvestments, diff --git a/functions/src/utils.ts b/functions/src/utils.ts index a0878e4f..23f7257a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,7 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' -import { Post } from 'common/post' +import { Post } from '../../common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index bc5e8cc6..14f14ea4 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react' import Router, { useRouter } from 'next/router' import { BetFillData, + ContractResolutionData, Notification, notification_source_types, } from 'common/notification' @@ -706,7 +707,7 @@ function NotificationItem(props: { isChildOfGroup?: boolean }) { const { notification, justSummary, isChildOfGroup } = props - const { sourceType, reason } = notification + const { sourceType, reason, sourceUpdateType } = notification const [highlighted] = useState(!notification.isSeen) @@ -724,6 +725,15 @@ function NotificationItem(props: { justSummary={justSummary} /> ) + } else if (sourceType === 'contract' && sourceUpdateType === 'resolved') { + return ( + + ) } // TODO Add new notification components here @@ -810,7 +820,8 @@ function NotificationFrame(props: { sourceText, } = notification const questionNeedsResolution = sourceUpdateType == 'closed' - + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 return (
{subtitle} {isChildOfGroup ? ( @@ -945,6 +956,83 @@ function BetFillNotification(props: { ) } +function ContractResolvedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText, data } = notification + const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} + const subtitle = 'resolved the market' + const resolutionDescription = () => { + if (!sourceText) return
+ if (sourceText === 'YES' || sourceText == 'NO') { + return + } + if (sourceText.includes('%')) + return + if (sourceText === 'CANCEL') return + if (sourceText === 'MKT' || sourceText === 'PROB') return + + // Numeric market + if (parseFloat(sourceText)) + return + + // Free response market + return ( +
+ +
+ ) + } + + const description = + userInvestment && userPayout ? ( + + {resolutionDescription()} + Invested: + {formatMoney(userInvestment)} + Payout: + 0 ? 'text-primary' : 'text-red-500', + 'truncate' + )} + > + {formatMoney(userPayout)} + {` (${userPayout > 0 ? '+' : '-'}${Math.round( + ((userPayout - userInvestment) / userInvestment) * 100 + )}%)`} + + + ) : ( + {resolutionDescription()} + ) + + if (justSummary) { + return ( + + {description} + + ) + } + + return ( + + + {description} + + + ) +} + export const setNotificationsAsSeen = async (notifications: Notification[]) => { const unseenNotifications = notifications.filter((n) => !n.isSeen) return await Promise.all( @@ -1064,30 +1152,7 @@ function NotificationTextLabel(props: { if (sourceType === 'contract') { if (justSummary || !sourceText) return
// Resolved contracts - if (sourceType === 'contract' && sourceUpdateType === 'resolved') { - { - if (sourceText === 'YES' || sourceText == 'NO') { - return - } - if (sourceText.includes('%')) - return ( - - ) - if (sourceText === 'CANCEL') return - if (sourceText === 'MKT' || sourceText === 'PROB') return - // Numeric market - if (parseFloat(sourceText)) - return - - // Free response market - return ( -
- -
- ) - } - } // Close date will be a number - it looks better without it if (sourceUpdateType === 'closed') { return
From 61c672ce4c959f26451158219086b98faccc2f39 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 15 Sep 2022 15:50:26 -0600 Subject: [PATCH 2/8] Show negative payouts --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 14f14ea4..a0c1ede5 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -989,7 +989,7 @@ function ContractResolvedNotification(props: { } const description = - userInvestment && userPayout ? ( + userInvestment && userPayout !== undefined ? ( {resolutionDescription()} Invested: @@ -1002,7 +1002,7 @@ function ContractResolvedNotification(props: { )} > {formatMoney(userPayout)} - {` (${userPayout > 0 ? '+' : '-'}${Math.round( + {` (${userPayout > 0 ? '+' : ''}${Math.round( ((userPayout - userInvestment) / userInvestment) * 100 )}%)`} From 3362b2f953639a74621ae2d06c19d4f51dea6de1 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 15 Sep 2022 15:51:39 -0600 Subject: [PATCH 3/8] Capitalize --- web/components/contract/contract-info-dialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 9027d38a..76a48277 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,6 +19,7 @@ import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' import { BETTORS } from 'common/user' +import { capitalize } from 'lodash' 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' @@ -136,7 +137,7 @@ export function ContractInfoDialog(props: { */} - {BETTORS} + {capitalize(BETTORS)} {bettorsCount} From e9fcf5a352626b293c281fa49275154c4918b7fb Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 15 Sep 2022 16:12:05 -0600 Subject: [PATCH 4/8] Space --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 8dc7928a..2b24fa60 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -242,7 +242,7 @@ export function UserPage(props: { user: User }) { Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! {' '} - You've gotten + You've gotten{' '} Date: Thu, 15 Sep 2022 15:12:26 -0700 Subject: [PATCH 5/8] Use %mention to embed a contract card in rich text editor (#869) * Bring up a list of contracts with @ * Fix hot reload for RichContent * Render contracts as half-size cards * Use % as the prompt; allow for spaces * WIP: When there's no matching question, create a new contract * Revert "WIP: When there's no matching question, create a new contract" This reverts commit efae1bf715dfe02b88169d181a22d6f0fe7ad480. * Rename to contract-mention * WIP: Try to merge in @ and % side by side * Add a different pluginKey * Track the prosemirror-state dep --- web/components/editor.tsx | 19 ++++- .../editor/contract-mention-list.tsx | 68 +++++++++++++++++ .../editor/contract-mention-suggestion.ts | 76 +++++++++++++++++++ web/components/editor/contract-mention.tsx | 41 ++++++++++ web/hooks/use-contracts.ts | 9 ++- web/package.json | 1 + 6 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 web/components/editor/contract-mention-list.tsx create mode 100644 web/components/editor/contract-mention-suggestion.ts create mode 100644 web/components/editor/contract-mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 745fc3c5..95f18b3f 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' +import { contractMentionSuggestion } from './editor/contract-mention-suggestion' +import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -97,7 +99,12 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ suggestion: mentionSuggestion }), + DisplayMention.configure({ + suggestion: mentionSuggestion, + }), + DisplayContractMention.configure({ + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], @@ -316,13 +323,21 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, + DisplayContractMention.configure({ + // Needed to set a different PluginKey for Prosemirror + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect(() => void editor?.commands?.setContent(content), [editor, content]) + useEffect( + // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 + () => void !editor?.isDestroyed && editor?.commands?.setContent(content), + [editor, content] + ) return } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx new file mode 100644 index 00000000..bda9d2fc --- /dev/null +++ b/web/components/editor/contract-mention-list.tsx @@ -0,0 +1,68 @@ +import { SuggestionProps } from '@tiptap/suggestion' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { contractPath } from 'web/lib/firebase/contracts' +import { Avatar } from '../avatar' + +// copied from https://tiptap.dev/api/nodes/mention#usage +const M = forwardRef((props: SuggestionProps, ref) => { + const { items: contracts, command } = props + + const [selectedIndex, setSelectedIndex] = useState(0) + useEffect(() => setSelectedIndex(0), [contracts]) + + const submitUser = (index: number) => { + const contract = contracts[index] + if (contract) + command({ id: contract.id, label: contractPath(contract) } as any) + } + + const onUp = () => + setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) + const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) + const onEnter = () => submitUser(selectedIndex) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: any) => { + if (event.key === 'ArrowUp') { + onUp() + return true + } + if (event.key === 'ArrowDown') { + onDown() + return true + } + if (event.key === 'Enter') { + onEnter() + return true + } + return false + }, + })) + + return ( +
+ {!contracts.length ? ( + No results... + ) : ( + contracts.map((contract, i) => ( + + )) + )} +
+ ) +}) + +// Just to keep the formatting pretty +export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts new file mode 100644 index 00000000..79525cfc --- /dev/null +++ b/web/components/editor/contract-mention-suggestion.ts @@ -0,0 +1,76 @@ +import type { MentionOptions } from '@tiptap/extension-mention' +import { ReactRenderer } from '@tiptap/react' +import { searchInAny } from 'common/util/parse' +import { orderBy } from 'lodash' +import tippy from 'tippy.js' +import { getCachedContracts } from 'web/hooks/use-contracts' +import { MentionList } from './contract-mention-list' +import { PluginKey } from 'prosemirror-state' + +type Suggestion = MentionOptions['suggestion'] + +const beginsWith = (text: string, query: string) => + text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) + +// copied from https://tiptap.dev/api/nodes/mention#usage +// TODO: merge with mention-suggestion.ts? +export const contractMentionSuggestion: Suggestion = { + char: '%', + allowSpaces: true, + pluginKey: new PluginKey('contract-mention'), + items: async ({ query }) => + orderBy( + (await getCachedContracts()).filter((c) => + searchInAny(query, c.question) + ), + [(c) => [c.question].some((s) => beginsWith(s, query))], + ['desc', 'desc'] + ).slice(0, 5), + render: () => { + let component: ReactRenderer + let popup: ReturnType + return { + onStart: (props) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as any, + appendTo: () => document.body, + content: component?.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect as any, + }) + }, + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide() + return true + } + return (component?.ref as any)?.onKeyDown(props) + }, + onExit() { + popup?.[0].destroy() + component?.destroy() + }, + } + }, +} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx new file mode 100644 index 00000000..9e967044 --- /dev/null +++ b/web/components/editor/contract-mention.tsx @@ -0,0 +1,41 @@ +import Mention from '@tiptap/extension-mention' +import { + mergeAttributes, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react' +import clsx from 'clsx' +import { useContract } from 'web/hooks/use-contract' +import { ContractCard } from '../contract/contract-card' + +const name = 'contract-mention-component' + +const ContractMentionComponent = (props: any) => { + const contract = useContract(props.node.attrs.id) + + return ( + + {contract && ( + + )} + + ) +} + +/** + * Mention extension that renders React. See: + * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions + * https://tiptap.dev/guide/node-views/react#render-a-react-component + */ +export const DisplayContractMention = Mention.extend({ + parseHTML: () => [{ tag: name }], + renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], + addNodeView: () => + ReactNodeViewRenderer(ContractMentionComponent, { + // On desktop, render cards below half-width so you can stack two + className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', + }), +}) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 1ea2f232..87eefa38 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,8 +9,9 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, + listAllContracts, } from 'web/lib/firebase/contracts' -import { useQueryClient } from 'react-query' +import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -23,6 +24,12 @@ export const useContracts = () => { return contracts } +const q = new QueryClient() +export const getCachedContracts = async () => + q.fetchQuery(['contracts'], () => listAllContracts(1000), { + staleTime: Infinity, + }) + export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index 114ded1e..ba25a6e1 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", + "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1", From ebbb8905e2653d69120adb864ad1fd5a2e0c99ab Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Thu, 15 Sep 2022 16:05:56 -0700 Subject: [PATCH 6/8] Add clearer thinking Regrant to tournaments (#883) --- web/pages/tournaments/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index e81c239f..8ce11284 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -76,6 +76,13 @@ const Salem = { } const tourneys: Tourney[] = [ + { + title: 'Clearer Thinking Regrant Project', + blurb: 'Which projects will Clearer Thinking give a grant to?', + award: '$13,000', + endTime: toDate('Sep 22, 2022'), + groupId: 'fhksfIgqyWf7OxsV9nkM', + }, { title: 'Manifold F2P Tournament', blurb: @@ -99,13 +106,6 @@ const tourneys: Tourney[] = [ endTime: toDate('Jan 6, 2023'), groupId: 'SxGRqXRpV3RAQKudbcNb', }, - // { - // title: 'Clearer Thinking Regrant Project', - // blurb: 'Something amazing', - // award: '$10,000', - // endTime: toDate('Sep 22, 2022'), - // groupId: '2VsVVFGhKtIdJnQRAXVb', - // }, // Tournaments without awards get featured belows { From e0634cea6d7c0a169829c04ed061f602b4fcfb1e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 15 Sep 2022 18:19:22 -0500 Subject: [PATCH 7/8] Revert "Use %mention to embed a contract card in rich text editor (#869)" This reverts commit 140628692f9e09b6f2e582b31f88974dac581524. --- web/components/editor.tsx | 19 +---- .../editor/contract-mention-list.tsx | 68 ----------------- .../editor/contract-mention-suggestion.ts | 76 ------------------- web/components/editor/contract-mention.tsx | 41 ---------- web/hooks/use-contracts.ts | 9 +-- web/package.json | 1 - 6 files changed, 3 insertions(+), 211 deletions(-) delete mode 100644 web/components/editor/contract-mention-list.tsx delete mode 100644 web/components/editor/contract-mention-suggestion.ts delete mode 100644 web/components/editor/contract-mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 95f18b3f..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,8 +21,6 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' -import { contractMentionSuggestion } from './editor/contract-mention-suggestion' -import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -99,12 +97,7 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ - suggestion: mentionSuggestion, - }), - DisplayContractMention.configure({ - suggestion: contractMentionSuggestion, - }), + DisplayMention.configure({ suggestion: mentionSuggestion }), Iframe, TiptapTweet, ], @@ -323,21 +316,13 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, - DisplayContractMention.configure({ - // Needed to set a different PluginKey for Prosemirror - suggestion: contractMentionSuggestion, - }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect( - // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 - () => void !editor?.isDestroyed && editor?.commands?.setContent(content), - [editor, content] - ) + useEffect(() => void editor?.commands?.setContent(content), [editor, content]) return } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx deleted file mode 100644 index bda9d2fc..00000000 --- a/web/components/editor/contract-mention-list.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { SuggestionProps } from '@tiptap/suggestion' -import clsx from 'clsx' -import { Contract } from 'common/contract' -import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' -import { contractPath } from 'web/lib/firebase/contracts' -import { Avatar } from '../avatar' - -// copied from https://tiptap.dev/api/nodes/mention#usage -const M = forwardRef((props: SuggestionProps, ref) => { - const { items: contracts, command } = props - - const [selectedIndex, setSelectedIndex] = useState(0) - useEffect(() => setSelectedIndex(0), [contracts]) - - const submitUser = (index: number) => { - const contract = contracts[index] - if (contract) - command({ id: contract.id, label: contractPath(contract) } as any) - } - - const onUp = () => - setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) - const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) - const onEnter = () => submitUser(selectedIndex) - - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: any) => { - if (event.key === 'ArrowUp') { - onUp() - return true - } - if (event.key === 'ArrowDown') { - onDown() - return true - } - if (event.key === 'Enter') { - onEnter() - return true - } - return false - }, - })) - - return ( -
- {!contracts.length ? ( - No results... - ) : ( - contracts.map((contract, i) => ( - - )) - )} -
- ) -}) - -// Just to keep the formatting pretty -export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts deleted file mode 100644 index 79525cfc..00000000 --- a/web/components/editor/contract-mention-suggestion.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { MentionOptions } from '@tiptap/extension-mention' -import { ReactRenderer } from '@tiptap/react' -import { searchInAny } from 'common/util/parse' -import { orderBy } from 'lodash' -import tippy from 'tippy.js' -import { getCachedContracts } from 'web/hooks/use-contracts' -import { MentionList } from './contract-mention-list' -import { PluginKey } from 'prosemirror-state' - -type Suggestion = MentionOptions['suggestion'] - -const beginsWith = (text: string, query: string) => - text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) - -// copied from https://tiptap.dev/api/nodes/mention#usage -// TODO: merge with mention-suggestion.ts? -export const contractMentionSuggestion: Suggestion = { - char: '%', - allowSpaces: true, - pluginKey: new PluginKey('contract-mention'), - items: async ({ query }) => - orderBy( - (await getCachedContracts()).filter((c) => - searchInAny(query, c.question) - ), - [(c) => [c.question].some((s) => beginsWith(s, query))], - ['desc', 'desc'] - ).slice(0, 5), - render: () => { - let component: ReactRenderer - let popup: ReturnType - return { - onStart: (props) => { - component = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }) - if (!props.clientRect) { - return - } - - popup = tippy('body', { - getReferenceClientRect: props.clientRect as any, - appendTo: () => document.body, - content: component?.element, - showOnCreate: true, - interactive: true, - trigger: 'manual', - placement: 'bottom-start', - }) - }, - onUpdate(props) { - component?.updateProps(props) - - if (!props.clientRect) { - return - } - - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect as any, - }) - }, - onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } - return (component?.ref as any)?.onKeyDown(props) - }, - onExit() { - popup?.[0].destroy() - component?.destroy() - }, - } - }, -} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx deleted file mode 100644 index 9e967044..00000000 --- a/web/components/editor/contract-mention.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Mention from '@tiptap/extension-mention' -import { - mergeAttributes, - NodeViewWrapper, - ReactNodeViewRenderer, -} from '@tiptap/react' -import clsx from 'clsx' -import { useContract } from 'web/hooks/use-contract' -import { ContractCard } from '../contract/contract-card' - -const name = 'contract-mention-component' - -const ContractMentionComponent = (props: any) => { - const contract = useContract(props.node.attrs.id) - - return ( - - {contract && ( - - )} - - ) -} - -/** - * Mention extension that renders React. See: - * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions - * https://tiptap.dev/guide/node-views/react#render-a-react-component - */ -export const DisplayContractMention = Mention.extend({ - parseHTML: () => [{ tag: name }], - renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], - addNodeView: () => - ReactNodeViewRenderer(ContractMentionComponent, { - // On desktop, render cards below half-width so you can stack two - className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', - }), -}) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 87eefa38..1ea2f232 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,9 +9,8 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, - listAllContracts, } from 'web/lib/firebase/contracts' -import { QueryClient, useQueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -24,12 +23,6 @@ export const useContracts = () => { return contracts } -const q = new QueryClient() -export const getCachedContracts = async () => - q.fetchQuery(['contracts'], () => listAllContracts(1000), { - staleTime: Infinity, - }) - export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index ba25a6e1..114ded1e 100644 --- a/web/package.json +++ b/web/package.json @@ -48,7 +48,6 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", - "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1", From 5a1cc4c19d3444a32764f811131530a5dd8889e0 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Thu, 15 Sep 2022 18:32:30 -0500 Subject: [PATCH 8/8] getCpmmInvested: fix NaN issue --- common/calculate.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/common/calculate.ts b/common/calculate.ts index da4ce13a..e4c9ed07 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) { const { outcome, shares, amount } = bet if (floatingEqual(shares, 0)) continue + const spent = totalSpent[outcome] ?? 0 + const position = totalShares[outcome] ?? 0 + if (amount > 0) { - totalShares[outcome] = (totalShares[outcome] ?? 0) + shares - totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + amount } else if (amount < 0) { - const averagePrice = totalSpent[outcome] / totalShares[outcome] - totalShares[outcome] = totalShares[outcome] + shares - totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares + const averagePrice = position === 0 ? 0 : spent / position + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + averagePrice * shares } } - return sum(Object.values(totalSpent)) + return sum([0, ...Object.values(totalSpent)]) } function getDpmInvested(yourBets: Bet[]) {