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[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
} & T } & T
export type BinaryContract = Contract & Binary 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 UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 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_AMOUNT?: number
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
@ -70,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
'taowell@gmail.com', // Stephen 'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair 'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

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

View File

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

View File

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

View File

@ -8,11 +8,11 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "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-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@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" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {

View File

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

2
dev.sh
View File

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

View File

@ -11,7 +11,8 @@ service cloud.firestore {
'jahooma@gmail.com', 'jahooma@gmail.com',
'taowell@gmail.com', 'taowell@gmail.com',
'abc.sinclair@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 read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && 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 // User referral rules
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +45,11 @@ service cloud.firestore {
allow read; allow read;
} }
match /contracts/{contractId}/follows/{userId} {
allow read;
allow create, delete: if userId == request.auth.uid;
}
match /contracts/{contractId}/challenges/{challengeId}{ match /contracts/{contractId}/challenges/{challengeId}{
allow read; allow read;
allow create: if request.auth.uid == request.resource.data.creatorId; 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 read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin()) allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys() && 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} { match /private-users/{userId}/views/{viewId} {

View File

@ -13,7 +13,7 @@
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"dev": "nodemon src/serve.ts", "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", "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: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", "db:backup-local": "firebase emulators:export --force ./firestore_export",
@ -26,11 +26,11 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@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-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@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", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "express": "4.18.1",

View File

@ -15,15 +15,17 @@ import {
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' 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 { APIError, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE } from '../../common/economy' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes, getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash' import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const user = userDoc.data() as User const user = userDoc.data() as User
const ante = FIXED_ANTE 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 // 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}.`) throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null let group: Group | null = null
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
visibility 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) 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') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`) .collection(`contracts/${contract.id}/liquidity`)

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getValues } from './utils' import { getValues, log } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
@ -33,19 +33,12 @@ export const createNotification = async (
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
relatedSourceType?: notification_source_types
recipients?: string[] recipients?: string[]
slug?: string slug?: string
title?: string title?: string
} }
) => { ) => {
const { const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, 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 ( const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts 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 = ( const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
followedUserId: string 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 = ( const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
relatedUserId: string relatedUserId: string
@ -266,58 +159,289 @@ export const createNotification = async (
} }
} }
const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {}
const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place.
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined. if (sourceType === 'follow' && recipients?.[0]) {
if (!sourceContract) return userToReasonTexts notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
if ( sourceType === 'group' &&
sourceType === 'comment' || sourceUpdateType === 'created' &&
sourceType === 'answer' || recipients
(sourceType === 'contract' && ) {
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
) { } else if (
if (sourceType === 'comment') { sourceType === 'contract' &&
if (recipients?.[0] && relatedSourceType) sourceUpdateType === 'created' &&
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) sourceContract
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) ) {
} await notifyUsersFollowers(userToReasonTexts)
await notifyContractCreator(userToReasonTexts, sourceContract) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) } else if (
await notifyLiquidityProviders(userToReasonTexts, sourceContract) sourceType === 'contract' &&
await notifyBettorsOnContract(userToReasonTexts, sourceContract) sourceUpdateType === 'closed' &&
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) sourceContract
} else if (sourceType === 'contract' && sourceUpdateType === 'created') { ) {
await notifyUsersFollowers(userToReasonTexts) await notifyContractCreator(userToReasonTexts, sourceContract, {
notifyTaggedUsers(userToReasonTexts, recipients ?? []) force: true,
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') { })
await notifyContractCreator(userToReasonTexts, sourceContract, { } else if (
force: true, sourceType === 'liquidity' &&
}) sourceUpdateType === 'created' &&
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { sourceContract
await notifyContractCreator(userToReasonTexts, sourceContract) ) {
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract)
// Note: the daily bonus won't have a contract attached to it } else if (
await notifyContractCreatorOfUniqueBettorsBonus( sourceType === 'bonus' &&
userToReasonTexts, sourceUpdateType === 'created' &&
sourceContract.creatorId sourceContract
) ) {
} // Note: the daily bonus won't have a contract attached to it
return userToReasonTexts await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
const userToReasonTexts = await getUsersToNotify() 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))
})
)
}
// 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) await createUsersNotifications(userToReasonTexts)
} }

View File

@ -2,13 +2,8 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { import { PrivateUser, User } from '../../common/user'
MANIFOLD_AVATAR_URL, import { getUser, getUserByUsername, getValues } from './utils'
MANIFOLD_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import {
cleanDisplayName, cleanDisplayName,
@ -23,10 +18,6 @@ import {
import { track } from './analytics' import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' 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' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({ const bodySchema = z.object({
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
.update({ .update({
memberIds: uniq(group.memberIds.concat(user.id)), 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 subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText = // const creatorPayoutText =
userId === creator.id // userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)` // ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: '' // : ''
const emailType = 'market-resolved' const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
question: contract.question, question: contract.question,
outcome, outcome,
investment: `${Math.floor(investment)}`, investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`, payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl, unsubscribeUrl,
} }
@ -116,7 +116,9 @@ const toDisplayResolution = (
} }
if (contract.outcomeType === 'PSEUDO_NUMERIC') { if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract const { resolution, resolutionValue } = contract
if (resolution === 'CANCEL') return 'N/A'
return resolutionValue return resolutionValue
? formatLargeNumber(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 './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag' export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow'
// v2 // v2
export * from './health' export * from './health'

View File

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

View File

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

View File

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

View File

@ -1,7 +1,13 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils' import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification' 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 export const onCreateLiquidityProvision = functions.firestore
.document('contracts/{contractId}/liquidity/{liquidityId}') .document('contracts/{contractId}/liquidity/{liquidityId}')
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
const { eventId } = context const { eventId } = context
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision // 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) const contract = await getContract(liquidity.contractId)
if (!contract) if (!contract)
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId) const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification( await createNotification(
contract.id, 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 * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}` resolutionText = `${contract.resolutionValue}`
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'resolved', 'resolved',
contractUpdater, contractUpdater,
eventId, eventId,
resolutionText, resolutionText,
{ contract } contract
) )
} else if ( } else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore
sourceText = contract.question sourceText = contract.question
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'updated', 'updated',
contractUpdater, contractUpdater,
eventId, eventId,
sourceText, sourceText,
{ contract } contract
) )
} }
}) })

View File

@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math' import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
return { betId: betDoc.id, makers, newBet } return { betId: betDoc.id, makers, newBet }
}) })
await addUserToContractFollowers(contractId, auth.uid)
log('Main transaction finished.') log('Main transaction finished.')
if (result.newBet.amount !== 0) { 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 { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), 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)) const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.') log('Share redemption transaction finished.')

View File

@ -12,8 +12,8 @@ const firestore = admin.firestore()
export const updateLoans = functions export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 }) .runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every Monday. // Run every day at midnight.
.pubsub.schedule('0 0 * * 1') .pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles') .timeZone('America/Los_Angeles')
.onRun(updateLoansCore) .onRun(updateLoansCore)
@ -79,9 +79,13 @@ async function updateLoansCore() {
const today = new Date().toDateString().replace(' ', '-') const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}` const key = `loan-notifications-${today}`
await Promise.all( await Promise.all(
userPayouts.map(({ user, payout }) => userPayouts
createLoanIncomeNotification(user, key, payout) // 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)
)
) )
log('Notifications sent!') log('Notifications sent!')

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -33,7 +34,8 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount) const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group mb-4"> <label className="input-group mb-4">
@ -50,6 +52,7 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} 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 yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2 const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours') const startDate = new Date(contract.createdTime)
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
? new Date(contract.createdTime) ? latestTime.add(1, 'hours').toDate()
: hoursAgo.toDate() : latestTime.toDate()
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
xScale={{ xScale={{
type: 'time', type: 'time',
min: startDate, min: startDate,
max: latestTime.toDate(), max: endDate,
}} }}
xFormat={(d) => xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
} }
axisBottom={{ axisBottom={{
tickValues: numXTickValues, 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} pointSize={0}
curve="stepAfter" curve="stepAfter"
enableSlices="x" enableSlices="x"
@ -156,7 +165,11 @@ function formatTime(
) { ) {
const d = dayjs(time) 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 let format: string
if (d.isSame(Date.now(), 'day')) { if (d.isSame(Date.now(), 'day')) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -86,10 +86,12 @@ export function ContractsGrid(props: {
/> />
))} ))}
</Masonry> </Masonry>
<VisibilityObserver {loadMore && (
onVisibilityUpdated={onVisibilityUpdated} <VisibilityObserver
className="relative -top-96 h-1" onVisibilityUpdated={onVisibilityUpdated}
/> className="relative -top-96 h-1"
/>
)}
</Col> </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 { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal' import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { FollowMarketButton } from 'web/components/follow-market-button'
export function ShareRow(props: { export function ShareRow(props: {
contract: Contract contract: Contract
@ -25,7 +26,7 @@ export function ShareRow(props: {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
return ( return (
<Row className="mt-2"> <Row className="mt-0.5 sm:mt-2">
<Button <Button
size="lg" size="lg"
color="gray-white" color="gray-white"
@ -62,6 +63,7 @@ export function ShareRow(props: {
/> />
</Button> </Button>
)} )}
<FollowMarketButton contract={contract} user={user} />
</Row> </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 { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar' 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 { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import clsx from 'clsx' import clsx from 'clsx'
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
@ -44,7 +42,6 @@ export function BottomNavBar() {
const currentPage = router.pathname const currentPage = router.pathname
const user = useUser() const user = useUser()
const privateUser = usePrivateUser()
const isIframe = useIsIframe() const isIframe = useIsIframe()
if (isIframe) { if (isIframe) {
@ -85,11 +82,7 @@ export function BottomNavBar() {
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" /> <MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
{privateUser ? ( More
<MoreMenuWithGroupNotifications privateUser={privateUser} />
) : (
'More'
)}
</div> </div>
<MobileSidebar <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 }) { function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)

View File

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

View File

@ -11,11 +11,12 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span> <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'}> <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'}> <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>
<span className={'text-indigo-700'}> <span className={'text-indigo-700'}>
Do I have to pay back a loan? Do I have to pay back a loan?
@ -33,12 +34,12 @@ export function LoansModal(props: {
</span> </span>
<span className={'text-indigo-700'}> What is an example?</span> <span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}> <span className={'ml-2'}>
For example, if you bet M$100 on "Will I become a millionare?" on For example, if you bet M$1000 on "Will I become a millionare?" on
Sunday, you will get M$5 back on Monday. Monday, you will get M$10 back on Tuesday.
</span> </span>
<span className={'ml-2'}> <span className={'ml-2'}>
Previous loans count against your total bet amount. So, the next Previous loans count against your total bet amount. So on Wednesday,
week, you would get back 5% of M$95 = M$4.75. you would get back 1% of M$990 = M$9.9.
</span> </span>
</Col> </Col>
</Col> </Col>

View File

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

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => { export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>() const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds 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 = [ const lessPriorityReasons = [
'on_contract_with_users_comment', 'on_contract_with_users_comment',
'on_contract_with_users_answer', 'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out', 'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less: // Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in', // '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) 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) { function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()
@ -272,6 +295,26 @@ export async function getClosingSoonContracts() {
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) 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) { export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id) 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/line": "0.74.0",
"@nivo/tooltip": "0.74.0", "@nivo/tooltip": "0.74.0",
"@react-query-firebase/firestore": "0.4.2", "@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-character-count": "2.0.0-beta.31",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/extension-placeholder": "2.0.0-beta.53", "@tiptap/extension-placeholder": "2.0.0-beta.53",
"@tiptap/react": "2.0.0-beta.114", "@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", "algoliasearch": "4.13.0",
"browser-image-compression": "2.0.0", "browser-image-compression": "2.0.0",
"clsx": "1.1.1", "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 { ArrowLeftIcon } from '@heroicons/react/outline'
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview' import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel' import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col' 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 { ResolutionPanel } from 'web/components/resolution-panel'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { import {
Contract, Contract,
getContractFromSlug, getContractFromSlug,
getRandTopCreatorContracts,
tradingAllowed, tradingAllowed,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404' import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel' import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz' 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 { ContractTabs } from 'web/components/contract/contract-tabs'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti' import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { NumericBetPanel } from 'web/components/numeric-bet-panel' 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 { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' 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 { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractComment } from 'common/comment' 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 { 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 const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const [bets, comments] = await Promise.all([ const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [], contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [], contractId ? listAllComments(contractId) : [],
contract
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
]) ])
return { 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. // Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000), bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000), comments: comments.slice(0, 1000),
recommendedContracts,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -81,6 +84,7 @@ export default function ContractPage(props: {
bets: Bet[] bets: Bet[]
comments: ContractComment[] comments: ContractComment[]
slug: string slug: string
recommendedContracts: Contract[]
backToHome?: () => void backToHome?: () => void
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
@ -88,6 +92,7 @@ export default function ContractPage(props: {
username: '', username: '',
comments: [], comments: [],
bets: [], bets: [],
recommendedContracts: [],
slug: '', slug: '',
} }
@ -149,7 +154,7 @@ export function ContractPageContent(
user?: User | null user?: User | null
} }
) { ) {
const { backToHome, comments, user } = props const { backToHome, comments, user, recommendedContracts } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
@ -263,128 +268,13 @@ export function ContractPageContent(
comments={comments} comments={comments}
/> />
</Col> </Col>
{recommendedContracts.length > 0 && (
<Col className="gap-2 px-2 sm:px-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
</Col>
)}
</Page> </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 { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts' import { Contract, contractPath } from 'web/lib/firebase/contracts'
import { createMarket } from 'web/lib/firebase/api' 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 { InfoTooltip } from 'web/components/info-tooltip'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -158,6 +158,8 @@ export function NewContract(props: {
: undefined : undefined
const balance = creator.balance || 0 const balance = creator.balance || 0
const deservesFreeMarket =
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
const min = minString ? parseFloat(minString) : undefined const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined const max = maxString ? parseFloat(maxString) : undefined
@ -177,7 +179,7 @@ export function NewContract(props: {
question.length > 0 && question.length > 0 &&
ante !== undefined && ante !== undefined &&
ante !== null && ante !== null &&
ante <= balance && (ante <= balance || deservesFreeMarket) &&
// closeTime must be in the future // closeTime must be in the future
closeTime && closeTime &&
closeTime > Date.now() && closeTime > Date.now() &&
@ -461,12 +463,25 @@ export function NewContract(props: {
text={`Cost to create your question. This amount is used to subsidize betting.`} text={`Cost to create your question. This amount is used to subsidize betting.`}
/> />
</label> </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>
)}
<div className="label-text text-neutral pl-1"> {ante > balance && !deservesFreeMarket && (
{formatMoney(ante)}
</div>
{ante > balance && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> <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> <span className="mr-2 text-red-500">Insufficient balance</span>
<button <button

View File

@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
contract={contract} contract={contract}
bets={bets} bets={bets}
isCreator={false} isCreator={false}
user={null}
disabled 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} /> <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 { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { FollowList } from 'web/components/follow-list' import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline' import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { CopyLinkButton } from 'web/components/copy-link-button' 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 { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment' import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
@ -149,9 +146,6 @@ export default function GroupPage(props: {
const page = slugs?.[1] as typeof groupSubpages[number] const page = slugs?.[1] as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser() 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 = [ const tabs = [
{ {
title: 'Markets', title: 'Markets',
content: questionsTab, content: questionsTab,
href: groupPath(group.slug, 'markets'), href: groupPath(group.slug, 'markets'),
}, },
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, 'chat'),
},
{ {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboard, content: leaderboard,

View File

@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
backToHome={() => { backToHome={() => {
history.back() 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 { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings' import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { useUser } from 'web/hooks/use-user'
export const NOTIFICATIONS_PER_PAGE = 30 export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers' const MULTIPLE_USERS_KEY = 'multipleUsers'
@ -199,7 +200,9 @@ function IncomeNotificationGroupItem(props: {
const { notificationGroup, className } = props const { notificationGroup, className } = props
const { notifications } = notificationGroup const { notifications } = notificationGroup
const numSummaryLines = 3 const numSummaryLines = 3
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState( const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen) notifications.some((n) => !n.isSeen)
) )
@ -378,6 +381,8 @@ function IncomeNotificationItem(props: {
const [highlighted] = useState(!notification.isSeen) const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = (width && width < 768) || false const isMobile = (width && width < 768) || false
const user = useUser()
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
}, [notification]) }, [notification])
@ -397,16 +402,16 @@ function IncomeNotificationItem(props: {
} else if (sourceType === 'betting_streak_bonus') { } else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your' reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) { } 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 = const bettingStreakText =
sourceType === 'betting_streak_bonus' && sourceType === 'betting_streak_bonus' &&
(sourceText (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
? `🔥 ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
} day Betting Streak`
: 'Betting Streak')
return ( return (
<> <>
@ -521,7 +526,7 @@ function IncomeNotificationItem(props: {
</span> </span>
</div> </div>
</Row> </Row>
<div className={'mt-4 border-b border-gray-300'} /> <div className={'border-b border-gray-300 pt-4'} />
</div> </div>
</div> </div>
) )
@ -538,7 +543,9 @@ function NotificationGroupItem(props: {
const isMobile = (width && width < 768) || false const isMobile = (width && width < 768) || false
const numSummaryLines = 3 const numSummaryLines = 3
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState( const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen) 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 { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user' import { User, PrivateUser } from 'common/user'
import { import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
getUserAndPrivateUser,
updateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page' import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' 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) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
} }
const updateApiKey = async (e: React.MouseEvent) => { const updateApiKey = async (e: React.MouseEvent) => {
const newApiKey = crypto.randomUUID() const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey) setApiKey(newApiKey ?? '')
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
setApiKey(privateUser.apiKey || '')
})
e.preventDefault() e.preventDefault()
} }
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
</button> </button>
</div> </div>
</div> </div>
<TwitchPanel />
</Col> </Col>
</Col> </Col>
</Page> </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.isplainobject "^4.0.6"
lodash.merge "^4.6.2" lodash.merge "^4.6.2"
"@tiptap/core@2.0.0-beta.181", "@tiptap/core@^2.0.0-beta.181": "@tiptap/core@2.0.0-beta.182", "@tiptap/core@^2.0.0-beta.182":
version "2.0.0-beta.181" version "2.0.0-beta.182"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.181.tgz#07aeea26336814ab82eb7f4199b17538187c6fbb" resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.182.tgz#d2001e9b765adda95e15d171479860a3349e2d04"
integrity sha512-tbwRqjTVvY9v31TNAH6W0Njhr/OVwI28zWXmH55/USrwyU2CB1iCVfXktZKOhB+8WyvOaBv1JA5YplMIhstYTw== integrity sha512-MZGkMGnVnWhBzjvpBNwQ9zBz38ndi3Irbf90uCTSArR0kaCVkW4vmyuPuOXd+0SO8Yv/l5oyDdOCpaG3rnQYfw==
dependencies: dependencies:
prosemirror-commands "1.3.0" prosemirror-commands "1.3.0"
prosemirror-keymap "1.2.0" prosemirror-keymap "1.2.0"
@ -3099,12 +3099,12 @@
"@tiptap/extension-floating-menu" "^2.0.0-beta.56" "@tiptap/extension-floating-menu" "^2.0.0-beta.56"
prosemirror-view "1.26.2" prosemirror-view "1.26.2"
"@tiptap/starter-kit@2.0.0-beta.190": "@tiptap/starter-kit@2.0.0-beta.191":
version "2.0.0-beta.190" version "2.0.0-beta.191"
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.190.tgz#fe0021e29d070fc5707722513a398c8884e15f71" resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.191.tgz#3f549367f6dbb8cf83f63aa0941722d91d0fd8e7"
integrity sha512-jaFMkE6mjCHmCJsXUyLiXGYRVDcHF+PbH/5hEu1riUIAT0Hmm7uak5TYsPeuoCVN7P/tmDEBbBRASZ5CzEQpvw== integrity sha512-YRrBCi9W4jiH/xLTJJOCdD7pL4Wb98Ip8qCJ94RElShDj0O1i5tT9wWlgVWoGIU+CRAds5XENRwZ97sJ+YfYyg==
dependencies: 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-blockquote" "^2.0.0-beta.29"
"@tiptap/extension-bold" "^2.0.0-beta.28" "@tiptap/extension-bold" "^2.0.0-beta.28"
"@tiptap/extension-bullet-list" "^2.0.0-beta.29" "@tiptap/extension-bullet-list" "^2.0.0-beta.29"