Compare commits

...

57 Commits

Author SHA1 Message Date
mantikoros
34ad32430a twitch panel logic 2022-08-29 01:18:52 -05:00
mantikoros
1ba1acdd1d hide text if linked 2022-08-29 00:57:54 -05:00
mantikoros
bfef873b53 working linking logic 2022-08-29 00:51:25 -05:00
mantikoros
6b05561517 generateNewApiKey 2022-08-26 11:54:13 -05:00
mantikoros
050c80609b manually merge in Phil's code 2022-08-26 11:35:32 -05:00
James Grugett
d710a9b669 If there is a group for a market on market page, clicking it goes to group 2022-08-26 11:22:07 -05:00
James Grugett
74765281b3 Make graph start from left side for new markets 2022-08-26 11:22:07 -05:00
Austin Chen
b2362769a5 Switch to a different color scheme 2022-08-26 11:22:07 -05:00
James Grugett
488b442f66 Render graph for multiple choice embeds 2022-08-26 11:22:07 -05:00
James Grugett
04a8509212 Decrease starting time window for free response graph 2022-08-26 11:22:07 -05:00
James Grugett
eb630dfa46 Expand notifications by default if <= 3 items 2022-08-26 11:22:07 -05:00
Sinclair Chen
e3c307bbda bump tiptap version to fix multi-italic bug (#801) 2022-08-26 11:22:07 -05:00
Austin Chen
539ef017e0 Clean up Featured code 2022-08-26 11:22:07 -05:00
Austin Chen
ba106a258a Tweak Featured badge design 2022-08-26 11:22:07 -05:00
James Grugett
4af5ed4bd4 Add debug console.log 2022-08-26 11:22:07 -05:00
Ian Philips
346d24868e With play money on numeric & center text 2022-08-26 11:22:07 -05:00
James Grugett
dd42b641ac Move recommended markets below market white bg onto gray bg 2022-08-26 11:22:07 -05:00
Ian Philips
937ff991df With play money 2022-08-26 11:22:07 -05:00
Ian Philips
2156784348 Add david to admins 2022-08-26 11:22:07 -05:00
Ian Philips
0cd9e52689 Feature markets on trending 2022-08-26 11:22:07 -05:00
Ian Philips
934156c770 Give all users 5 free markets 2022-08-26 11:22:07 -05:00
Ian Philips
cfb2e1fc3a Show old streak for old streak notifs 2022-08-26 11:22:07 -05:00
Ian Philips
c8fd6940c1 Show old streak for old streak notifs 2022-08-26 11:22:07 -05:00
mantikoros
b4394c8663 resolution email: show n/a for canceled numeric 2022-08-26 11:22:07 -05:00
James Grugett
57090d8480 Fix betting streak number 2022-08-26 11:22:07 -05:00
James Grugett
2282cb544f Increase memory for updateStats function 2022-08-26 11:22:07 -05:00
James Grugett
a7a45d1968 Weekly => daily loans 2022-08-26 11:22:07 -05:00
James Grugett
5333f881ef Some other follow to watch changes 2022-08-26 11:22:07 -05:00
James Grugett
0428708e82 Convert heart to eye and follow to watch 2022-08-26 11:22:07 -05:00
Ian Philips
85473b17c3 Betting streak bonus per day:10, max:50 2022-08-26 11:22:07 -05:00
Ian Philips
c45faf6b80 Set max betting bonus to M 2022-08-26 11:22:07 -05:00
James Grugett
830ef498d8 Create experimental home page 2022-08-26 11:22:07 -05:00
Ian Philips
ab3c9d250d Reduce share row top margin on mobile 2022-08-26 11:22:07 -05:00
Ian Philips
32113a29e3 Gap adjustment 2022-08-26 11:22:07 -05:00
Ian Philips
33f593ebf7 Move share button back down, small spacing tweaks 2022-08-26 11:22:07 -05:00
Ian Philips
e79235afef Small ux tweaks for signed out market page 2022-08-26 11:22:07 -05:00
mantikoros
fd0aa30195 fix sidebar tracking 2022-08-26 11:22:07 -05:00
SirSaltyy
310d9e0ccc Change about button (#796)
About button name change and now directs to "Help and About Center" super.so
2022-08-26 11:22:07 -05:00
Ian Philips
b48cd939bf Remove bolded More from navbar 2022-08-26 11:22:07 -05:00
Ian Philips
ed9d0cfd95 Shrink icon 2022-08-26 11:22:07 -05:00
Ian Philips
c753313546 💔💔💔 2022-08-26 11:22:07 -05:00
mantikoros
2e0957d739 eslint 2022-08-26 11:22:07 -05:00
mantikoros
acf9640b7a eslint 2022-08-26 11:22:07 -05:00
James Grugett
33c55f43bf Fix types 2022-08-26 11:22:07 -05:00
James Grugett
2fcfeb786a Simple recommended contracts based on contract creator 2022-08-26 11:22:07 -05:00
Ian Philips
0743d4ae1c Lint 2022-08-26 11:22:07 -05:00
Ian Philips
d44482aae3 Heart button on xl style 2022-08-26 11:22:06 -05:00
Ian Philips
7899d71af6 Show all groups on sidebar 2022-08-26 11:22:06 -05:00
Ian Philips
1ed46948b1 Remove group chat display 2022-08-26 11:22:06 -05:00
Ian Philips
edb8591cab Fix import 2022-08-26 11:22:06 -05:00
Ian Philips
18b0051450 Fix import 2022-08-26 11:22:06 -05:00
Ian Philips
54f7b740dc Allow to follow/unfollow markets, backfill as well (#794)
* Allow to follow/unfollow markets, backfill as well

* remove yarn script edit

* add decrement comment

* Lint

* Decrement follow count on unfollow

* Follow/unfollow button logic

* Unfollow/follow => heart

* Add user to followers in place-bet and sell-shares

* Add tracking

* Show contract follow modal for first time following

* Increment follower count as well

* Remove add follow from bet trigger

* restore on-create-bet

* Add pubsub to dev.sh, show heart on FR, remove from answer trigger
2022-08-26 11:22:06 -05:00
James Grugett
3ce2bdc9f1 Dedup contract leaderboards code from contract slug (merge error?) 2022-08-26 11:22:06 -05:00
Sinclair Chen
5904038890 Fix bet modal probability sticking (#793)
* Fix button group styles
* Reset prob strike-out when bet modal closed
2022-08-26 11:22:06 -05:00
mantikoros
6929d643e1 eliminate fees 2022-08-24 15:28:14 -05:00
mantikoros
50279bd864 useRedirectAfterSignup 2022-08-23 12:37:02 -05:00
mantikoros
2e95ac449d twitch landing page (needs work) 2022-08-23 12:19:36 -05:00
70 changed files with 1583 additions and 669 deletions

View File

@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
} & T
export type BinaryContract = Contract & Binary

View File

@ -11,6 +11,7 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100
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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5

View File

@ -35,6 +35,7 @@ export type Economy = {
BETTING_STREAK_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
}
type FirebaseConfig = {
@ -70,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
],
visibility: 'PUBLIC',

View File

@ -1,9 +1,9 @@
export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0.1
export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0
export const DPM_PLATFORM_FEE = 0.01
export const DPM_CREATOR_FEE = 0.04
export const DPM_PLATFORM_FEE = 0.0
export const DPM_CREATOR_FEE = 0.0
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
export type Fees = {

View File

@ -10,11 +10,11 @@ import {
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_WEEKLY_RATE = 0.05
const LOAN_DAILY_RATE = 0.01
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_WEEKLY_RATE
return netValue * LOAN_DAILY_RATE
}
export const getLoanUpdates = (

View File

@ -70,3 +70,4 @@ export type notification_reason_types =
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'

View File

@ -8,11 +8,11 @@
},
"sideEffects": false,
"dependencies": {
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"lodash": "4.17.21"
},
"devDependencies": {

View File

@ -42,6 +42,8 @@ export type User = {
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
}
export type PrivateUser = {
@ -60,6 +62,10 @@ export type PrivateUser = {
initialIpAddress?: string
apiKey?: string
notificationPreferences?: notification_subscribe_types
twitchInfo?: {
twitchName: string
controlToken: string
}
}
export type notification_subscribe_types = 'all' | 'less' | 'none'

2
dev.sh
View File

@ -24,7 +24,7 @@ then
npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \
-c green,white,magenta,cyan \
"yarn --cwd=functions firestore" \
"yarn --cwd=functions localDbScript" \
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \

View File

@ -11,7 +11,8 @@ service cloud.firestore {
'jahooma@gmail.com',
'taowell@gmail.com',
'abc.sinclair@gmail.com',
'manticmarkets@gmail.com'
'manticmarkets@gmail.com',
'iansphilips@gmail.com'
]
}
@ -23,7 +24,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +45,11 @@ service cloud.firestore {
allow read;
}
match /contracts/{contractId}/follows/{userId} {
allow read;
allow create, delete: if userId == request.auth.uid;
}
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
@ -64,7 +70,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
.hasOnly(['apiKey', 'twitchInfo', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
}
match /private-users/{userId}/views/{viewId} {

View File

@ -13,7 +13,7 @@
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
@ -26,11 +26,11 @@
"dependencies": {
"@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",

View File

@ -15,15 +15,17 @@ import {
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { chargeUser, getContract } from './utils'
import { chargeUser, getContract, isProd } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE } from '../../common/economy'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const user = userDoc.data() as User
const ante = FIXED_ANTE
const deservesFreeMarket =
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
// TODO: this is broken because it's not in a transaction
if (ante > user.balance)
if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
visibility
)
if (ante) await chargeUser(user.id, ante, true)
const providerId = deservesFreeMarket
? isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id
if (ante) await chargeUser(providerId, ante, true)
if (deservesFreeMarket)
await firestore
.collection('users')
.doc(user.id)
.update({ freeMarketsCreated: FieldValue.increment(1) })
await contractRef.create(contract)
@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
}
}
const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getValues } from './utils'
import { getValues, log } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@ -33,19 +33,12 @@ export const createNotification = async (
sourceText: string,
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
recipients?: string[]
slug?: string
title?: string
}
) => {
const {
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldGetNotification = (
userId: string,
@ -90,24 +83,6 @@ export const createNotification = async (
)
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts,
contract: Contract
) => {
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (!shouldGetNotification(userId, userToReasonTexts)) return
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
@ -129,23 +104,6 @@ export const createNotification = async (
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
@ -182,71 +140,6 @@ export const createNotification = async (
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
@ -266,9 +159,9 @@ export const createNotification = async (
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
@ -277,47 +170,278 @@ export const createNotification = async (
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'created' &&
sourceContract
) {
if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
await createUsersNotifications(userToReasonTexts)
}
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedUserId?: string
taggedUserIds?: string[]
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
const userToReasonTexts = await getUsersToNotify()
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
)
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
}
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts)
}

View File

@ -2,13 +2,8 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { PrivateUser, User } from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
@ -23,10 +18,6 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
.update({
memberIds: uniq(group.memberIds.concat(user.id)),
})
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
if (slug === 'welcome') {
const welcomeCommentDoc = firestore
.collection(`groups/${group.id}/comments`)
.doc()
await welcomeCommentDoc.create({
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, @${user.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,
userAvatarUrl: MANIFOLD_AVATAR_URL,
})
}
}
}

View File

@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText =
userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
// const creatorPayoutText =
// userId === creator.id
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
// : ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
question: contract.question,
outcome,
investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`,
payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}
@ -116,7 +116,9 @@ const toDisplayResolution = (
}
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract
const { resolution, resolutionValue } = contract
if (resolution === 'CANCEL') return 'N/A'
return resolutionValue
? formatLargeNumber(resolutionValue)

View File

@ -0,0 +1,36 @@
import * as admin from 'firebase-admin'
const firestore = admin.firestore()
export const addUserToContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.set({
id: userId,
createdTime: Date.now(),
})
}
export const removeUserFromContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (!followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.delete()
}

View File

@ -30,6 +30,7 @@ export * from './score-contracts'
export * from './weekly-markets-emails'
export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow'
// v2
export * from './health'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
import { createNotification } from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Answer } from '../../common/answer'
export const onCreateAnswer = functions.firestore
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator')
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
answer.id,
'answer',
'created',
answerCreator,
eventId,
answer.text,
{ contract }
contract
)
})

View File

@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { createNotification } from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator')
await addUserToContractFollowers(contract.id, commentCreator.id)
await firestore
.collection('contracts')
.doc(contract.id)
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
const recipients = uniq(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
comment.id,
'comment',
'created',
commentCreator,
eventId,
richTextToString(comment.content),
{ contract, relatedSourceType, recipients }
contract,
{
relatedSourceType,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
}
)
const recipientUserIds = uniq([

View File

@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market'
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -18,6 +19,7 @@ export const onCreateContract = functions
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract.id, contractCreator.id)
await createNotification(
contract.id,

View File

@ -1,7 +1,13 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification'
import { LiquidityProvision } from 'common/liquidity-provision'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
export const onCreateLiquidityProvision = functions.firestore
.document('contracts/{contractId}/liquidity/{liquidityId}')
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
const { eventId } = context
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
if (
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
liquidity.amount === FIXED_ANTE
)
return
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
const contract = await getContract(liquidity.contractId)
if (!contract)
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification(
contract.id,

View File

@ -0,0 +1,45 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
export const onDeleteContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onDelete(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(-1),
})
})
export const onCreateContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(1),
})
})

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore
@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}`
}
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
contractUpdater,
eventId,
resolutionText,
{ contract }
contract
)
} else if (
previousValue.closeTime !== contract.closeTime ||
@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore
sourceText = contract.question
}
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
{ contract }
contract
)
}
})

View File

@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
return { betId: betDoc.id, makers, newBet }
})
await addUserToContractFollowers(contractId, auth.uid)
log('Main transaction finished.')
if (result.newBet.amount !== 0) {

View File

@ -0,0 +1,75 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Contract } from 'common/lib/contract'
import { Comment } from 'common/lib/comment'
import { uniq } from 'lodash'
import { Bet } from 'common/lib/bet'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/lib/antes'
const firestore = admin.firestore()
async function backfillContractFollowers() {
console.log('Backfilling contract followers')
const contracts = await getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
)
let count = 0
for (const contract of contracts) {
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contract.id).collection('comments')
)
const commenterIds = uniq(comments.map((comment) => comment.userId))
const betsSnap = await firestore
.collection(`contracts/${contract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const bettorIds = uniq(bets.map((bet) => bet.userId))
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
// exclude free market liquidity provider
).filter(
(id) =>
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
)
const followerIds = uniq([
...commenterIds,
...bettorIds,
...liquidityProvidersIds,
contract.creatorId,
])
for (const followerId of followerIds) {
await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(followerId)
.set({ id: followerId, createdTime: Date.now() })
}
// Perhaps handled by the trigger?
// const followerCount = followerIds.length
// await firestore
// .collection(`contracts`)
// .doc(contract.id)
// .update({ followerCount: followerCount })
count += 1
if (count % 100 === 0) {
console.log(`${count} contracts processed`)
}
}
}
if (require.main === module) {
backfillContractFollowers()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
})
)
return { newBet, makers }
return { newBet, makers, maxShares, soldShares }
})
if (result.maxShares === result.soldShares) {
await removeUserFromContractFollowers(contractId, auth.uid)
}
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.')

View File

@ -12,8 +12,8 @@ const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every Monday.
.pubsub.schedule('0 0 * * 1')
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
@ -79,7 +79,11 @@ async function updateLoansCore() {
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
userPayouts.map(({ user, payout }) =>
userPayouts
// Don't send a notification if the payout is < M$1,
// because a M$0 loan is confusing.
.filter(({ payout }) => payout >= 1)
.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
)

View File

@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { Col } from 'web/components/layout/col'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
export function NotificationSettings() {
const user = useUser()
@ -17,6 +19,7 @@ export function NotificationSettings() {
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
@ -121,12 +124,20 @@ export function NotificationSettings() {
}
function NotificationSettingLine(props: {
label: string
label: string | React.ReactNode
highlight: boolean
onClick?: () => void
}) {
const { label, highlight } = props
const { label, highlight, onClick } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
<Row
className={clsx(
'my-1 gap-1 text-gray-300',
highlight && '!text-black',
onClick ? 'cursor-pointer' : ''
)}
onClick={onClick}
>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
@ -148,31 +159,45 @@ export function NotificationSettings() {
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<Col className={''}>
<Row className={'my-1'}>
You will receive notifications for these general events:
</Row>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<Row className={'my-1'}>
You will receive new comment, answer, & resolution notifications on
questions:
</Row>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
highlight={notificationSettings !== 'none'}
label={
<span>
That <span className={'font-bold'}>you watch </span>- you
auto-watch questions if:
</span>
}
onClick={() => setShowModal(true)}
/>
</div>
</div>
<Col
className={clsx(
'mb-2 ml-8',
'gap-1 text-gray-300',
notificationSettings !== 'none' && '!text-black'
)}
>
<Row> You create it</Row>
<Row> You bet, comment on, or answer it</Row>
<Row> You add liquidity to it</Row>
<Row>
If you select 'Less' and you've commented on or answered a
question, you'll only receive notification on direct replies to
your comments or answers
</Row>
</Col>
</Col>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
@ -205,6 +230,7 @@ export function NotificationSettings() {
/>
</div>
</div>
<FollowMarketModal setOpen={setShowModal} open={showModal} />
</div>
)
}

View File

@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
export function AmountInput(props: {
amount: number | undefined
@ -33,7 +34,8 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount)
}
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return (
<Col className={className}>
<label className="input-group mb-4">
@ -50,6 +52,7 @@ export function AmountInput(props: {
inputMode="numeric"
placeholder="0"
maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''}
disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)}

View File

@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours')
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
? new Date(contract.createdTime)
: hoursAgo.toDate()
const startDate = new Date(contract.createdTime)
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours').toDate()
: latestTime.toDate()
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
xScale={{
type: 'time',
min: startDate,
max: latestTime.toDate(),
max: endDate,
}}
xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
format: (time) =>
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ scheme: 'pastel1' }}
colors={[
'#fca5a5', // red-300
'#93c5fd', // blue-300
'#86efac', // green-300
'#f9a8d4', // pink-300
'#a5b4fc', // indigo-300
'#fcd34d', // amber-300
]}
pointSize={0}
curve="stepAfter"
enableSlices="x"
@ -156,7 +165,11 @@ function formatTime(
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
if (
d.add(1, 'minute').isAfter(Date.now()) &&
d.subtract(1, 'minute').isBefore(Date.now())
)
return 'Now'
let format: string
if (d.isSame(Date.now(), 'day')) {

View File

@ -47,6 +47,7 @@ export function AuthProvider(props: {
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
console.log('onIdTokenChanged', fbUser)
if (fbUser) {
setTokenCookies({
id: await fbUser.getIdToken(),

View File

@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
import { Button } from 'web/components/button'
import { firebaseLogin } from 'web/lib/firebase/users'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -30,23 +32,27 @@ export default function BetButton(props: {
return (
<>
<Col className={clsx('items-center', className)}>
<button
className={clsx(
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
btnClassName
)}
onClick={() => setOpen(true)}
<Button
size={'lg'}
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
onClick={() => {
!user ? firebaseLogin() : setOpen(true)
}}
>
Bet
</button>
{user ? 'Bet' : 'Sign up to Bet'}
</Button>
{user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{hasYesShares
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
? `(${Math.floor(yesShares)} ${
isPseudoNumeric ? 'HIGHER' : 'YES'
})`
: hasNoShares
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
: ''}
</div>
)}
</Col>
<Modal open={open} setOpen={setOpen}>

View File

@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
setProbAfter: (probAfter: number) => void
setProbAfter: (probAfter: number | undefined) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
@ -82,7 +82,7 @@ export function BetInline(props: {
<div className="text-xl">Bet</div>
<YesNoSelector
className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome}
onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric}
@ -113,7 +113,12 @@ export function BetInline(props: {
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<button
onClick={() => {
setProbAfter(undefined)
onClose()
}}
>
<XIcon className="ml-1 h-6 w-6" />
</button>
</Row>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { clamp, partition, sum, sumBy } from 'lodash'
import { clamp, partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -9,7 +9,6 @@ import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import {
formatMoney,
formatMoneyWithDecimals,
formatPercent,
formatWithCommas,
} from 'common/util/format'
@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import {
BinaryOutcomeLabel,
HigherLabel,
@ -261,8 +259,6 @@ function BuyPanel(props: {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const totalFees = sum(Object.values(newBet.fees))
const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
@ -346,9 +342,9 @@ function BuyPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">

View File

@ -91,6 +91,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
isWholePage?: boolean
maxItems?: number
noControls?: boolean
}) {
const {
user,
@ -105,6 +107,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage,
useQuerySortUrlParams,
isWholePage,
maxItems,
noControls,
} = props
const [numPages, setNumPages] = useState(1)
@ -158,6 +162,8 @@ export function ContractSearch(props: {
const contracts = pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts =
pages.length === 0 ? undefined : contracts.slice(0, maxItems)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -175,10 +181,11 @@ export function ContractSearch(props: {
useQuerySortUrlParams={useQuerySortUrlParams}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
/>
<ContractsGrid
contracts={pages.length === 0 ? undefined : contracts}
loadMore={performQuery}
contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery}
showTime={showTime}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
user?: User | null
noControls?: boolean
}) {
const {
className,
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage,
useQuerySortUrlParams,
user,
noControls,
} = props
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
})
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
if (noControls) {
return <></>
}
return (
<Col
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}

View File

@ -5,12 +5,15 @@ import {
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import Router from 'next/router'
import clsx from 'clsx'
import { Editor } from '@tiptap/react'
import dayjs from 'dayjs'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page'
import { Contract, updateContract } from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from '../avatar'
@ -21,7 +24,6 @@ import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
@ -30,8 +32,9 @@ import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils'
import clsx from 'clsx'
import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date'
@ -72,6 +75,8 @@ export function MiscDetails(props: {
{'Resolved '}
{fromNow(resolutionTime || 0)}
</Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge />
) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
) : (
@ -134,6 +139,7 @@ export function AbbrContractDetails(props: {
export function ContractDetails(props: {
contract: Contract
bets: Bet[]
user: User | null | undefined
isCreator?: boolean
disabled?: boolean
}) {
@ -157,7 +163,7 @@ export function ContractDetails(props: {
)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<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}
@ -179,12 +185,18 @@ export function ContractDetails(props: {
<Row>
{disabled ? (
groupInfo
) : !groupToDisplay && !user ? (
<div />
) : (
<Button
size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'}
onClick={() => setOpen(!open)}
onClick={() =>
groupToDisplay
? Router.push(groupPath(groupToDisplay.slug))
: setOpen(!open)
}
>
{groupInfo}
</Button>
@ -206,10 +218,9 @@ export function ContractDetails(props: {
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{resolvedDate && contract.resolutionTime ? (
<>
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip
text="Market resolved:"
time={dayjs(contract.resolutionTime)}
@ -219,8 +230,9 @@ export function ContractDetails(props: {
</>
) : null}
{!resolvedDate && closeTime && (
{!resolvedDate && closeTime && user && (
<>
<ClockIcon className="h-5 w-5" />
<EditableCloseDate
closeTime={closeTime}
contract={contract}
@ -230,14 +242,15 @@ export function ContractDetails(props: {
)}
</Row>
)}
{user && (
<>
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</>
)}
</Row>
)
}

View File

@ -7,7 +7,7 @@ import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { contractPool } from 'web/lib/firebase/contracts'
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
@ -16,6 +16,8 @@ import { InfoTooltip } from '../info-tooltip'
import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
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'
@ -24,6 +26,9 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState(
(contract?.featuredOnHomeRank ?? 0) > 0
)
const isDev = useDev()
const isAdmin = useAdmin()
@ -46,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'Multiple choice'
: 'Numeric'
const onFeaturedToggle = async (enabled: boolean) => {
if (
enabled &&
(contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank)
) {
await updateContract(id, { featuredOnHomeRank: 1 })
setFeatured(true)
} else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await updateContract(id, { featuredOnHomeRank: deleteField() })
setFeatured(false)
}
}
return (
<>
<button
@ -110,10 +130,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<td>{formatMoney(contract.volume)}</td>
</tr>
<tr>
{/* <tr>
<td>Creator earnings</td>
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
</tr>
</tr> */}
<tr>
<td>Traders</td>
@ -138,6 +158,18 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</td>
</tr>
)}
{isAdmin && (
<tr>
<td>[ADMIN] Featured</td>
<td>
<ShortToggle
enabled={featured}
setEnabled={setFeatured}
onChange={onFeaturedToggle}
/>
</td>
</tr>
)}
</tbody>
</table>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
title="🏅 Top bettors"
users={users || []}
columns={[
{

View File

@ -3,7 +3,6 @@ import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { ContractProbGraph } from './contract-prob-graph'
import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
@ -39,15 +38,15 @@ export const ContractOverview = (props: {
return (
<Col className={clsx('mb-6', className)}>
<Col className="gap-4 px-2">
<Col className="gap-3 px-2 sm:gap-4">
<Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} />
</div>
<Row className={'hidden gap-3 xl:flex'}>
{isBinary && (
<BinaryResolutionOrChance
className="hidden items-end xl:flex"
className="items-end"
contract={contract}
large
/>
@ -56,35 +55,46 @@ export const ContractOverview = (props: {
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
className="items-end"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
className="items-end"
/>
)}
</Row>
</Row>
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract as CPMMBinaryContract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetButton contract={contract} />}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetButton contract={contract} />}
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
contract={contract}
bets={bets}
isCreator={isCreator}
user={user}
/>
</Col>
<Spacer h={4} />
<div className={'my-1 md:my-2'}></div>
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}

View File

@ -58,10 +58,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
const hoursAgo = latestTime.subtract(1, 'hours')
const startDate = dayjs(times[0]).isBefore(hoursAgo)
? times[0]
: hoursAgo.toDate()
const startDate = times[0]
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours').toDate()
: latestTime.toDate()
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
// Minimum number of points for the graph to have. For smooth tooltip movement
// On first load, width is undefined, skip adding extra points to let page load faster
@ -133,14 +134,15 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
xScale={{
type: 'time',
min: startDate,
max: latestTime.toDate(),
max: endDate,
}}
xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
format: (time) =>
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ datum: 'color' }}
curve="stepAfter"
@ -183,7 +185,11 @@ function formatTime(
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
if (
d.add(1, 'minute').isAfter(Date.now()) &&
d.subtract(1, 'minute').isBefore(Date.now())
)
return 'Now'
let format: string
if (d.isSame(Date.now(), 'day')) {

View File

@ -86,10 +86,12 @@ export function ContractsGrid(props: {
/>
))}
</Masonry>
{loadMore && (
<VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1"
/>
)}
</Col>
)
}

View File

@ -0,0 +1,9 @@
import { BadgeCheckIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-green-800">
<BadgeCheckIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -0,0 +1,40 @@
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { EyeIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
export const FollowMarketModal = (props: {
open: boolean
setOpen: (b: boolean) => void
title?: string
}) => {
const { open, setOpen, title } = props
return (
<Modal open={open} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" />
<span className="text-xl">{title ? title : 'Watching questions'}</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}>
You can receive notifications on questions you're interested in by
clicking the
<EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true"
/>
button on a question.
</span>
<span className={'text-indigo-700'}>
What types of notifications will I receive?
</span>
<span className={'ml-2'}>
You'll receive in-app notifications for new comments, answers, and
updates to the question.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -10,6 +10,7 @@ import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics'
import { FollowMarketButton } from 'web/components/follow-market-button'
export function ShareRow(props: {
contract: Contract
@ -25,7 +26,7 @@ export function ShareRow(props: {
const [isShareOpen, setShareOpen] = useState(false)
return (
<Row className="mt-2">
<Row className="mt-0.5 sm:mt-2">
<Button
size="lg"
color="gray-white"
@ -62,6 +63,7 @@ export function ShareRow(props: {
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} />
</Row>
)
}

View File

@ -0,0 +1,77 @@
import { Button } from 'web/components/button'
import {
Contract,
followContract,
unFollowContract,
} from 'web/lib/firebase/contracts'
import toast from 'react-hot-toast'
import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { User } from 'common/user'
import { useContractFollows } from 'web/hooks/use-follows'
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
import { useState } from 'react'
import { Row } from 'web/components/layout/row'
export const FollowMarketButton = (props: {
contract: Contract
user: User | undefined | null
}) => {
const { contract, user } = props
const followers = useContractFollows(contract.id)
const [open, setOpen] = useState(false)
return (
<Button
size={'lg'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
if (followers?.includes(user.id)) {
await unFollowContract(contract.id, user.id)
toast("You'll no longer receive notifications from this market", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Unwatch Market', {
slug: contract.slug,
})
} else {
await followContract(contract.id, user.id)
toast("You'll now receive notifications from this market!", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Watch Market', {
slug: contract.slug,
})
}
if (!user.hasSeenContractFollowModal) {
await updateUser(user.id, {
hasSeenContractFollowModal: true,
})
setOpen(true)
}
}}
>
{followers?.includes(user?.id ?? 'nope') ? (
<Row className={'gap-2'}>
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Unwatch
</Row>
) : (
<Row className={'gap-2'}>
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Watch
</Row>
)}
<FollowMarketModal
open={open}
setOpen={setOpen}
title={`You ${
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`}
/>
</Button>
)
}

View File

@ -9,7 +9,7 @@ import {
import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar'
import clsx from 'clsx'
@ -17,8 +17,6 @@ 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 { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
function getNavigation() {
return [
@ -44,7 +42,6 @@ export function BottomNavBar() {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser()
const isIframe = useIsIframe()
if (isIframe) {
@ -85,11 +82,7 @@ export function BottomNavBar() {
onClick={() => setSidebarOpen(true)}
>
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
{privateUser ? (
<MoreMenuWithGroupNotifications privateUser={privateUser} />
) : (
'More'
)}
More
</div>
<MobileSidebar
@ -100,22 +93,6 @@ export function BottomNavBar() {
)
}
function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) {
const { privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(privateUser, {
customHref: '/group/',
})
return (
<span
className={
preferredNotifications.length > 0 ? 'font-bold' : 'font-normal'
}
>
More
</span>
)
}
function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)

View File

@ -12,22 +12,20 @@ import {
import clsx from 'clsx'
import Link from 'next/link'
import Router, { useRouter } from 'next/router'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Group } from 'common/group'
import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
@ -95,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
{
name: 'Sign out',
href: '#',
@ -109,16 +107,16 @@ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
const signedOutMobileNavigation = [
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
@ -132,8 +130,8 @@ const signedInMobileNavigation = [
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
@ -228,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser()
// usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
@ -237,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
: signedInMobileNavigation
const memberItems = (
useMemberGroups(
user?.id,
{ withChatEnabled: true },
{ by: 'mostRecentChatActivityTime' }
) ?? []
useMemberGroups(user?.id, undefined, {
by: 'mostRecentContractAddedTime',
}) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}`,
@ -275,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && (
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div>
{/* Desktop navigation */}
@ -296,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
{/* Spacer if there are any groups */}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
{privateUser && (
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div>
</nav>
)
}
function GroupsList(props: {
currentPage: string
memberItems: Item[]
privateUser: PrivateUser
}) {
const { currentPage, memberItems, privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(
privateUser,
{
customHref: '/group/',
},
memberItems.length > 0 ? memberItems.length : undefined
)
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
const { currentPage, memberItems } = props
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo(
() => (itemHref: string) =>
preferredNotifications.some(
(n) =>
!n.isSeen &&
(n.isSeenOnHref === itemHref ||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
),
[preferredNotifications]
)
// const preferredNotifications = useUnseenPreferredNotifications(
// privateUser,
// {
// customHref: '/group/',
// },
// memberItems.length > 0 ? memberItems.length : undefined
// )
// const notifIsForThisItem = useMemo(
// () => (itemHref: string) =>
// preferredNotifications.some(
// (n) =>
// !n.isSeen &&
// (n.isSeenOnHref === itemHref ||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
// ),
// [preferredNotifications]
// )
return (
<>
@ -351,16 +329,12 @@ function GroupsList(props: {
>
{memberItems.map((item) => (
<a
href={
item.href +
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
}
href={item.href}
key={item.name}
onClick={trackCallback('sidebar: ' + item.name)}
onClick={trackCallback('click sidebar group', { name: item.name })}
className={clsx(
'cursor-pointer truncate',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
notifIsForThisItem(item.href) && 'font-bold'
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
)}
>
{item.name}

View File

@ -11,11 +11,12 @@ 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">Loans on your bets</span>
<span className="text-xl">Daily loans on your bets</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are loans?</span>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>
Every Monday, get 5% of your total bet amount back as a loan.
Every day at midnight PT, get 1% of your total bet amount back as a
loan.
</span>
<span className={'text-indigo-700'}>
Do I have to pay back a loan?
@ -33,12 +34,12 @@ export function LoansModal(props: {
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$100 on "Will I become a millionare?" on
Sunday, you will get M$5 back on Monday.
For example, if you bet M$1000 on "Will I become a millionare?" on
Monday, you will get M$10 back on Tuesday.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So, the next
week, you would get back 5% of M$95 = M$4.75.
Previous loans count against your total bet amount. So on Wednesday,
you would get back 1% of M$990 = M$9.9.
</span>
</Col>
</Col>

View File

@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button'
import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract'
import { formatMoney } from 'common/util/format'
export function ResolutionPanel(props: {
creator: User
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
}) {
const { contract, className } = props
const earnedFees =
contract.mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of trader profits`
: `${formatMoney(contract.collectedFees.creatorFee)} in fees`
// const earnedFees =
// contract.mechanism === 'dpm-2'
// ? `${DPM_CREATOR_FEE * 100}% of trader profits`
// : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
const [outcome, setOutcome] = useState<resolution | undefined>()
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
{outcome === 'YES' ? (
<>
Winnings will be paid out to YES bettors.
{/* <br />
<br />
<br />
You will earn {earnedFees}.
You will earn {earnedFees}. */}
</>
) : outcome === 'NO' ? (
<>
Winnings will be paid out to NO bettors.
{/* <br />
<br />
<br />
You will earn {earnedFees}.
You will earn {earnedFees}. */}
</>
) : outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.</>
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
probabilityInt={Math.round(prob)}
setProbabilityInt={setProb}
/>
You will earn {earnedFees}.
{/* You will earn {earnedFees}. */}
</Col>
) : (
<>Resolving this market will immediately pay out traders.</>

View File

@ -0,0 +1,96 @@
import clsx from 'clsx'
import React, { useState } from 'react'
import toast from 'react-hot-toast'
import { copyToClipboard } from 'web/lib/util/copy'
import { linkTwitchAccount } from 'web/lib/twitch/link-twitch-account'
import { LinkIcon } from '@heroicons/react/solid'
import { track } from 'web/lib/service/analytics'
import { Button } from './button'
import { LoadingIndicator } from './loading-indicator'
import { Row } from './layout/row'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
export function TwitchPanel() {
const user = useUser()
const privateUser = usePrivateUser()
const twitchName = privateUser?.twitchInfo?.twitchName
const twitchToken = privateUser?.twitchInfo?.controlToken
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 [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => {
if (!user || !privateUser) return
setTwitchLoading(true)
const promise = linkTwitchAccount(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"
>
<Button color="blue" size="lg" onClick={copyOverlayLink}>
Copy overlay link
</Button>
<Button color="indigo" size="lg" onClick={copyDockLink}>
Copy dock link
</Button>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -5,13 +5,19 @@ import clsx from 'clsx'
export default function ShortToggle(props: {
enabled: boolean
setEnabled: (enabled: boolean) => void
onChange?: (enabled: boolean) => void
}) {
const { enabled, setEnabled } = props
return (
<Switch
checked={enabled}
onChange={setEnabled}
onChange={(e: boolean) => {
setEnabled(e)
if (props.onChange) {
props.onChange(e)
}
}}
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="sr-only">Use setting</span>

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds
}
export const useContractFollows = (contractId: string) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
useEffect(() => {
return listenForContractFollows(contractId, setFollowIds)
}, [contractId])
return followIds
}

View File

@ -147,6 +147,7 @@ export function useUnseenPreferredNotifications(
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in',

View File

@ -0,0 +1,49 @@
import { User } from 'common/user'
import dayjs from 'dayjs'
import { useEffect } from 'react'
import { generateNewApiKey } from 'web/lib/api/api-key'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { initLinkTwitchAccount } from 'web/lib/twitch/link-twitch-account'
import { safeLocalStorage } from 'web/lib/util/local'
type page_redirects = 'twitch'
const key = 'redirect-after-signup'
export const useRedirectAfterSignup = (page: page_redirects) => {
useEffect(() => {
safeLocalStorage()?.setItem(key, page)
}, [page])
}
export const handleRedirectAfterSignup = async (user: User | null) => {
const redirect = safeLocalStorage()?.getItem(key)
if (!user || !redirect) return
safeLocalStorage()?.removeItem(key)
const now = dayjs().utc()
const userCreatedTime = dayjs(user.createdTime)
if (now.diff(userCreatedTime, 'minute') > 5) return
if (redirect === 'twitch') {
const { privateUser } = await getUserAndPrivateUser(user.id)
const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id))
if (!apiKey) return
try {
const [twitchAuthURL, linkSuccessPromise] = await initLinkTwitchAccount(
privateUser.id,
apiKey
)
window.open(twitchAuthURL) // TODO: Handle browser pop-up block
const data = await linkSuccessPromise // TODO: Do something with result?
console.debug(`Successfully linked Twitch account '${data.twitchName}'`)
} catch (e) {
console.error(e)
}
}
}

9
web/lib/api/api-key.ts Normal file
View File

@ -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)
}

View File

@ -212,6 +212,29 @@ export function listenForContract(
return listenForValue<Contract>(contractRef, setContract)
}
export function listenForContractFollows(
contractId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(contracts, contractId, 'follows')
return listenForValues<{ id: string }>(follows, (docs) =>
setFollowIds(docs.map(({ id }) => id))
)
}
export async function followContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
return await setDoc(followDoc, {
id: userId,
createdTime: Date.now(),
})
}
export async function unFollowContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
await deleteDoc(followDoc)
}
function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
@ -272,6 +295,26 @@ export async function getClosingSoonContracts() {
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
}
export const getRandTopCreatorContracts = async (
creatorId: string,
count: number,
excluding: string[] = []
) => {
const creatorContractsQuery = query(
contracts,
where('isResolved', '==', false),
where('creatorId', '==', creatorId),
orderBy('popularityScore', 'desc'),
limit(Math.max(count * 2, 15))
)
const data = await getValues<Contract>(creatorContractsQuery)
const open = data
.filter((c) => c.closeTime && c.closeTime > Date.now())
.filter((c) => !excluding.includes(c.id))
return chooseRandomSubset(open, count)
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id)

View File

@ -0,0 +1,47 @@
import { User, PrivateUser } from 'common/lib/user'
import { generateNewApiKey } from '../api/api-key'
import { updatePrivateUser } from '../firebase/users'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app'
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,
}),
})
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 linkTwitchAccount(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, linkSuccessPromise] = await initLinkTwitchAccount(
user.id,
apiKey
)
console.log('opening twitch link', twitchAuthURL)
window.open(twitchAuthURL)
const twitchInfo = await linkSuccessPromise
await updatePrivateUser(user.id, { twitchInfo })
console.log(`Successfully linked Twitch account '${twitchInfo.twitchName}'`)
}

View File

@ -27,14 +27,14 @@
"@nivo/line": "0.74.0",
"@nivo/tooltip": "0.74.0",
"@react-query-firebase/firestore": "0.4.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-character-count": "2.0.0-beta.31",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/extension-placeholder": "2.0.0-beta.53",
"@tiptap/react": "2.0.0-beta.114",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"algoliasearch": "4.13.0",
"browser-image-compression": "2.0.0",
"clsx": "1.1.1",

View File

@ -1,17 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col'
import { useUser, useUserById } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Spacer } from 'web/components/layout/spacer'
import {
Contract,
getContractFromSlug,
getRandTopCreatorContracts,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO'
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format'
import { ContractTabs } from 'web/components/contract/contract-tabs'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
@ -34,15 +31,17 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title'
import { FeedBet } from 'web/components/feed/feed-bets'
import { getOpenGraphProps } from 'common/contract-details'
import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
import { Subtitle } from 'web/components/subtitle'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const [bets, comments] = await Promise.all([
const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
contract
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
])
return {
@ -65,6 +67,7 @@ export async function getStaticPropz(props: {
// Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
recommendedContracts,
},
revalidate: 60, // regenerate after a minute
@ -81,6 +84,7 @@ export default function ContractPage(props: {
bets: Bet[]
comments: ContractComment[]
slug: string
recommendedContracts: Contract[]
backToHome?: () => void
}) {
props = usePropz(props, getStaticPropz) ?? {
@ -88,6 +92,7 @@ export default function ContractPage(props: {
username: '',
comments: [],
bets: [],
recommendedContracts: [],
slug: '',
}
@ -149,7 +154,7 @@ export function ContractPageContent(
user?: User | null
}
) {
const { backToHome, comments, user } = props
const { backToHome, comments, user, recommendedContracts } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
@ -263,128 +268,13 @@ export function ContractPageContent(
comments={comments}
/>
</Col>
{recommendedContracts.length > 0 && (
<Col className="gap-2 px-2 sm:px-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
</Col>
)}
</Page>
)
}
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts'
import { createMarket } from 'web/lib/firebase/api'
import { FIXED_ANTE } from 'common/economy'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy'
import { InfoTooltip } from 'web/components/info-tooltip'
import { Page } from 'web/components/page'
import { Row } from 'web/components/layout/row'
@ -158,6 +158,8 @@ export function NewContract(props: {
: undefined
const balance = creator.balance || 0
const deservesFreeMarket =
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined
@ -177,7 +179,7 @@ export function NewContract(props: {
question.length > 0 &&
ante !== undefined &&
ante !== null &&
ante <= balance &&
(ante <= balance || deservesFreeMarket) &&
// closeTime must be in the future
closeTime &&
closeTime > Date.now() &&
@ -461,12 +463,25 @@ export function NewContract(props: {
text={`Cost to create your question. This amount is used to subsidize betting.`}
/>
</label>
{!deservesFreeMarket ? (
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
) : (
<div>
<div className="label-text text-primary pl-1">
FREE{' '}
<span className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX -
(creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</span>
</div>
</div>
)}
{ante > balance && (
{ante > balance && !deservesFreeMarket && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span>
<button

View File

@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
contract={contract}
bets={bets}
isCreator={false}
user={null}
disabled
/>
@ -165,7 +166,8 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<AnswersGraph contract={contract} bets={bets} height={graphHeight} />
)}

View File

@ -0,0 +1,118 @@
import React from 'react'
import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/hooks/use-sort-and-query-params'
import { Button } from 'web/components/button'
import { Spacer } from 'web/components/layout/spacer'
import { useMemberGroups } from 'web/hooks/use-group'
import { Group } from 'common/group'
import { Title } from 'web/components/title'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null
const router = useRouter()
useTracking('view home')
useSaveReferral()
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => group.contractIds.length > 0
)
return (
<Page>
<Col className="mx-auto mb-8 w-full">
<SearchSection label="Trending" sort="score" user={user} />
<SearchSection label="Newest" sort="newest" user={user} />
<SearchSection label="Closing soon" sort="close-date" user={user} />
{memberGroups.map((group) => (
<GroupSection key={group.id} group={group} user={user} />
))}
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
router.push('/create')
track('mobile create button')
}}
>
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button>
</Page>
)
}
function SearchSection(props: {
label: string
user: User | null
sort: Sort
}) {
const { label, user, sort } = props
const router = useRouter()
return (
<Col>
<Title className="mx-2 !text-gray-800 sm:mx-0" text={label} />
<Spacer h={2} />
<ContractSearch user={user} defaultSort={sort} maxItems={4} noControls />
<Button
className="self-end"
color="blue"
size="sm"
onClick={() => router.push(`/home?s=${sort}`)}
>
See more
</Button>
</Col>
)
}
function GroupSection(props: { group: Group; user: User | null }) {
const { group, user } = props
const router = useRouter()
return (
<Col className="">
<Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} />
<Spacer h={2} />
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
maxItems={4}
noControls
/>
<Button
className="mr-2 self-end"
color="blue"
size="sm"
onClick={() => router.push(`/group/${group.slug}`)}
>
See more
</Button>
</Col>
)
}
export default Home

View File

@ -33,11 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ContractSearch } from 'web/components/contract-search'
import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { CopyLinkButton } from 'web/components/copy-link-button'
@ -46,7 +44,6 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat'
import { REFERRAL_AMOUNT } from 'common/economy'
export const getStaticProps = fromPropz(getStaticPropz)
@ -149,9 +146,6 @@ export default function GroupPage(props: {
const page = slugs?.[1] as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser()
@ -201,21 +195,12 @@ export default function GroupPage(props: {
/>
)
const chatTab = (
<GroupChat messages={messages} group={group} user={user} tips={tips} />
)
const tabs = [
{
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
},
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, 'chat'),
},
{
title: 'Leaderboards',
content: leaderboard,

View File

@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
backToHome={() => {
history.back()
}}
recommendedContracts={[]}
/>
)}
</>

View File

@ -44,6 +44,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO'
import { useUser } from 'web/hooks/use-user'
export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers'
@ -199,7 +200,9 @@ function IncomeNotificationGroupItem(props: {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen)
)
@ -378,6 +381,8 @@ function IncomeNotificationItem(props: {
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
const user = useUser()
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
@ -397,16 +402,16 @@ function IncomeNotificationItem(props: {
} else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as`
reasonText = `of your invested bets returned as a`
}
const streakInDays =
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
? `🔥 ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
} day Betting Streak`
: 'Betting Streak')
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
return (
<>
@ -521,7 +526,7 @@ function IncomeNotificationItem(props: {
</span>
</div>
</Row>
<div className={'mt-4 border-b border-gray-300'} />
<div className={'border-b border-gray-300 pt-4'} />
</div>
</div>
)
@ -538,7 +543,9 @@ function NotificationGroupItem(props: {
const isMobile = (width && width < 768) || false
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen)
)

View File

@ -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 { TwitchPanel } from 'web/components/twitch-panel'
import { generateNewApiKey } from 'web/lib/api/api-key'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.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>

115
web/pages/twitch.tsx Normal file
View File

@ -0,0 +1,115 @@
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 { linkTwitchAccount } from 'web/lib/twitch/link-twitch-account'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
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
? () => linkTwitchAccount(user, privateUser)
: async () => {
const result = await firebaseLogin()
const userId = result.user.uid
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
await linkTwitchAccount(user, privateUser)
}
const [isLoading, setLoading] = useState(false)
const getStarted = async () => {
setLoading(true)
const promise = callback()
track('twitch page button click')
await promise
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>
)
}

BIN
web/public/twitch-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -2919,10 +2919,10 @@
lodash.isplainobject "^4.0.6"
lodash.merge "^4.6.2"
"@tiptap/core@2.0.0-beta.181", "@tiptap/core@^2.0.0-beta.181":
version "2.0.0-beta.181"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.181.tgz#07aeea26336814ab82eb7f4199b17538187c6fbb"
integrity sha512-tbwRqjTVvY9v31TNAH6W0Njhr/OVwI28zWXmH55/USrwyU2CB1iCVfXktZKOhB+8WyvOaBv1JA5YplMIhstYTw==
"@tiptap/core@2.0.0-beta.182", "@tiptap/core@^2.0.0-beta.182":
version "2.0.0-beta.182"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.182.tgz#d2001e9b765adda95e15d171479860a3349e2d04"
integrity sha512-MZGkMGnVnWhBzjvpBNwQ9zBz38ndi3Irbf90uCTSArR0kaCVkW4vmyuPuOXd+0SO8Yv/l5oyDdOCpaG3rnQYfw==
dependencies:
prosemirror-commands "1.3.0"
prosemirror-keymap "1.2.0"
@ -3099,12 +3099,12 @@
"@tiptap/extension-floating-menu" "^2.0.0-beta.56"
prosemirror-view "1.26.2"
"@tiptap/starter-kit@2.0.0-beta.190":
version "2.0.0-beta.190"
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.190.tgz#fe0021e29d070fc5707722513a398c8884e15f71"
integrity sha512-jaFMkE6mjCHmCJsXUyLiXGYRVDcHF+PbH/5hEu1riUIAT0Hmm7uak5TYsPeuoCVN7P/tmDEBbBRASZ5CzEQpvw==
"@tiptap/starter-kit@2.0.0-beta.191":
version "2.0.0-beta.191"
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.191.tgz#3f549367f6dbb8cf83f63aa0941722d91d0fd8e7"
integrity sha512-YRrBCi9W4jiH/xLTJJOCdD7pL4Wb98Ip8qCJ94RElShDj0O1i5tT9wWlgVWoGIU+CRAds5XENRwZ97sJ+YfYyg==
dependencies:
"@tiptap/core" "^2.0.0-beta.181"
"@tiptap/core" "^2.0.0-beta.182"
"@tiptap/extension-blockquote" "^2.0.0-beta.29"
"@tiptap/extension-bold" "^2.0.0-beta.28"
"@tiptap/extension-bullet-list" "^2.0.0-beta.29"