Compare commits
57 Commits
main
...
twitch-lan
Author | SHA1 | Date | |
---|---|---|---|
|
34ad32430a | ||
|
1ba1acdd1d | ||
|
bfef873b53 | ||
|
6b05561517 | ||
|
050c80609b | ||
|
d710a9b669 | ||
|
74765281b3 | ||
|
b2362769a5 | ||
|
488b442f66 | ||
|
04a8509212 | ||
|
eb630dfa46 | ||
|
e3c307bbda | ||
|
539ef017e0 | ||
|
ba106a258a | ||
|
4af5ed4bd4 | ||
|
346d24868e | ||
|
dd42b641ac | ||
|
937ff991df | ||
|
2156784348 | ||
|
0cd9e52689 | ||
|
934156c770 | ||
|
cfb2e1fc3a | ||
|
c8fd6940c1 | ||
|
b4394c8663 | ||
|
57090d8480 | ||
|
2282cb544f | ||
|
a7a45d1968 | ||
|
5333f881ef | ||
|
0428708e82 | ||
|
85473b17c3 | ||
|
c45faf6b80 | ||
|
830ef498d8 | ||
|
ab3c9d250d | ||
|
32113a29e3 | ||
|
33f593ebf7 | ||
|
e79235afef | ||
|
fd0aa30195 | ||
|
310d9e0ccc | ||
|
b48cd939bf | ||
|
ed9d0cfd95 | ||
|
c753313546 | ||
|
2e0957d739 | ||
|
acf9640b7a | ||
|
33c55f43bf | ||
|
2fcfeb786a | ||
|
0743d4ae1c | ||
|
d44482aae3 | ||
|
7899d71af6 | ||
|
1ed46948b1 | ||
|
edb8591cab | ||
|
18b0051450 | ||
|
54f7b740dc | ||
|
3ce2bdc9f1 | ||
|
5904038890 | ||
|
6929d643e1 | ||
|
50279bd864 | ||
|
2e95ac449d |
|
@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
uniqueBettorIds?: string[]
|
||||
uniqueBettorCount?: number
|
||||
popularityScore?: number
|
||||
followerCount?: number
|
||||
featuredOnHomeRank?: number
|
||||
} & T
|
||||
|
||||
export type BinaryContract = Contract & Binary
|
||||
|
|
|
@ -11,6 +11,7 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
|
|||
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
|
||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
|
||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||
|
|
|
@ -35,6 +35,7 @@ export type Economy = {
|
|||
BETTING_STREAK_BONUS_AMOUNT?: number
|
||||
BETTING_STREAK_BONUS_MAX?: number
|
||||
BETTING_STREAK_RESET_HOUR?: number
|
||||
FREE_MARKETS_PER_USER_MAX?: number
|
||||
}
|
||||
|
||||
type FirebaseConfig = {
|
||||
|
@ -70,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
'taowell@gmail.com', // Stephen
|
||||
'abc.sinclair@gmail.com', // Sinclair
|
||||
'manticmarkets@gmail.com', // Manifold
|
||||
'iansphilips@gmail.com', // Ian
|
||||
'd4vidchee@gmail.com', // D4vid
|
||||
],
|
||||
visibility: 'PUBLIC',
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
export const PLATFORM_FEE = 0
|
||||
export const CREATOR_FEE = 0.1
|
||||
export const CREATOR_FEE = 0
|
||||
export const LIQUIDITY_FEE = 0
|
||||
|
||||
export const DPM_PLATFORM_FEE = 0.01
|
||||
export const DPM_CREATOR_FEE = 0.04
|
||||
export const DPM_PLATFORM_FEE = 0.0
|
||||
export const DPM_CREATOR_FEE = 0.0
|
||||
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
||||
|
||||
export type Fees = {
|
||||
|
|
|
@ -10,11 +10,11 @@ import {
|
|||
import { PortfolioMetrics, User } from './user'
|
||||
import { filterDefined } from './util/array'
|
||||
|
||||
const LOAN_WEEKLY_RATE = 0.05
|
||||
const LOAN_DAILY_RATE = 0.01
|
||||
|
||||
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
|
||||
const netValue = investedValue - loanTotal
|
||||
return netValue * LOAN_WEEKLY_RATE
|
||||
return netValue * LOAN_DAILY_RATE
|
||||
}
|
||||
|
||||
export const getLoanUpdates = (
|
||||
|
|
|
@ -70,3 +70,4 @@ export type notification_reason_types =
|
|||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
| 'loan_income'
|
||||
| 'you_follow_contract'
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -42,6 +42,8 @@ export type User = {
|
|||
shouldShowWelcome?: boolean
|
||||
lastBetTime?: number
|
||||
currentBettingStreak?: number
|
||||
hasSeenContractFollowModal?: boolean
|
||||
freeMarketsCreated?: number
|
||||
}
|
||||
|
||||
export type PrivateUser = {
|
||||
|
@ -60,6 +62,10 @@ export type PrivateUser = {
|
|||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
twitchInfo?: {
|
||||
twitchName: string
|
||||
controlToken: string
|
||||
}
|
||||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
|||
npx concurrently \
|
||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||
-c green,white,magenta,cyan \
|
||||
"yarn --cwd=functions firestore" \
|
||||
"yarn --cwd=functions localDbScript" \
|
||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||
|
|
|
@ -11,7 +11,8 @@ service cloud.firestore {
|
|||
'jahooma@gmail.com',
|
||||
'taowell@gmail.com',
|
||||
'abc.sinclair@gmail.com',
|
||||
'manticmarkets@gmail.com'
|
||||
'manticmarkets@gmail.com',
|
||||
'iansphilips@gmail.com'
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -23,7 +24,7 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
|
||||
// User referral rules
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -44,6 +45,11 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/follows/{userId} {
|
||||
allow read;
|
||||
allow create, delete: if userId == request.auth.uid;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/challenges/{challengeId}{
|
||||
allow read;
|
||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||
|
@ -64,7 +70,7 @@ service cloud.firestore {
|
|||
allow read: if userId == request.auth.uid || isAdmin();
|
||||
allow update: if (userId == request.auth.uid || isAdmin())
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
|
||||
.hasOnly(['apiKey', 'twitchInfo', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
|
||||
}
|
||||
|
||||
match /private-users/{userId}/views/{viewId} {
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log",
|
||||
"dev": "nodemon src/serve.ts",
|
||||
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
||||
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
|
@ -26,11 +26,11 @@
|
|||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
|
|
|
@ -15,15 +15,17 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { chargeUser, getContract, isProd } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import { FIXED_ANTE } from '../../common/economy'
|
||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
|
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
|
|||
import { JSONContent } from '@tiptap/core'
|
||||
import { uniq, zip } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
const user = userDoc.data() as User
|
||||
|
||||
const ante = FIXED_ANTE
|
||||
|
||||
const deservesFreeMarket =
|
||||
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||
// TODO: this is broken because it's not in a transaction
|
||||
if (ante > user.balance)
|
||||
if (ante > user.balance && !deservesFreeMarket)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
let group: Group | null = null
|
||||
|
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
visibility
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
const providerId = deservesFreeMarket
|
||||
? isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: user.id
|
||||
|
||||
if (ante) await chargeUser(providerId, ante, true)
|
||||
if (deservesFreeMarket)
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({ freeMarketsCreated: FieldValue.increment(1) })
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
|
@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const liquidityDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getValues } from './utils'
|
||||
import { getValues, log } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
|
@ -33,19 +33,12 @@ export const createNotification = async (
|
|||
sourceText: string,
|
||||
miscData?: {
|
||||
contract?: Contract
|
||||
relatedSourceType?: notification_source_types
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
) => {
|
||||
const {
|
||||
contract: sourceContract,
|
||||
relatedSourceType,
|
||||
recipients,
|
||||
slug,
|
||||
title,
|
||||
} = miscData ?? {}
|
||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
|
@ -90,24 +83,6 @@ export const createNotification = async (
|
|||
)
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
contract: Contract
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (!shouldGetNotification(userId, userToReasonTexts)) return
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
|
@ -129,23 +104,6 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyFollowedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
followedUserId: string
|
||||
|
@ -182,71 +140,6 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUserAddedToGroup = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
|
@ -266,9 +159,9 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
|
@ -277,47 +170,278 @@ export const createNotification = async (
|
|||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
if (!sourceContract) return userToReasonTexts
|
||||
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (recipients?.[0] && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
} else if (
|
||||
sourceType === 'liquidity' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
} else if (
|
||||
sourceType === 'bonus' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
return userToReasonTexts
|
||||
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: notification_source_types,
|
||||
sourceUpdateType: notification_source_update_types,
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
sourceContract: Contract,
|
||||
miscData?: {
|
||||
relatedSourceType?: notification_source_types
|
||||
repliedUserId?: string
|
||||
taggedUserIds?: string[]
|
||||
}
|
||||
) => {
|
||||
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
|
||||
|
||||
const createUsersNotifications = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
await Promise.all(
|
||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${userId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId,
|
||||
reason: userToReasonTexts[userId].reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId,
|
||||
sourceType,
|
||||
sourceUpdateType,
|
||||
sourceContractId: sourceContract.id,
|
||||
sourceUserName: sourceUser.name,
|
||||
sourceUserUsername: sourceUser.username,
|
||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||
sourceText,
|
||||
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
||||
sourceContractTitle: sourceContract.question,
|
||||
sourceContractSlug: sourceContract.slug,
|
||||
sourceSlug: sourceContract.slug,
|
||||
sourceTitle: sourceContract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const userToReasonTexts = await getUsersToNotify()
|
||||
// get contract follower documents and check here if they're a follower
|
||||
const contractFollowersSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/follows`)
|
||||
.get()
|
||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||
(doc) => doc.data().id
|
||||
)
|
||||
log('contractFollowerIds', contractFollowersIds)
|
||||
|
||||
const stillFollowingContract = (userId: string) => {
|
||||
return contractFollowersIds.includes(userId)
|
||||
}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
return (
|
||||
sourceUser.id != userId &&
|
||||
!Object.keys(userToReasonTexts).includes(userId)
|
||||
)
|
||||
}
|
||||
|
||||
const notifyContractFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
for (const userId of contractFollowersIds) {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'you_follow_contract',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContractCreator = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
||||
stillFollowingContract(sourceContract.creatorId)
|
||||
)
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'on_users_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
||||
stillFollowingContract(relatedUserId)
|
||||
) {
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
console.log('tagged user: ', id)
|
||||
// Allowing non-following users to get tagged
|
||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
) {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
|
||||
if (sourceType === 'comment') {
|
||||
if (repliedUserId && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
||||
await notifyLiquidityProviders(userToReasonTexts)
|
||||
await notifyBettorsOnContract(userToReasonTexts)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts)
|
||||
// if they weren't added previously, add them now
|
||||
await notifyContractFollowers(userToReasonTexts)
|
||||
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,8 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USERNAME,
|
||||
PrivateUser,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues, isProd } from './utils'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues } from './utils'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import {
|
||||
cleanDisplayName,
|
||||
|
@ -23,10 +18,6 @@ import {
|
|||
import { track } from './analytics'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||
|
||||
const bodySchema = z.object({
|
||||
|
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
|
|||
.update({
|
||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
||||
})
|
||||
const manifoldAccount = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
if (slug === 'welcome') {
|
||||
const welcomeCommentDoc = firestore
|
||||
.collection(`groups/${group.id}/comments`)
|
||||
.doc()
|
||||
await welcomeCommentDoc.create({
|
||||
id: welcomeCommentDoc.id,
|
||||
groupId: group.id,
|
||||
userId: manifoldAccount,
|
||||
text: `Welcome, @${user.username} aka ${user.name}!`,
|
||||
createdTime: Date.now(),
|
||||
userName: 'Manifold Markets',
|
||||
userUsername: MANIFOLD_USERNAME,
|
||||
userAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||
|
||||
const creatorPayoutText =
|
||||
userId === creator.id
|
||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
: ''
|
||||
// const creatorPayoutText =
|
||||
// userId === creator.id
|
||||
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
// : ''
|
||||
|
||||
const emailType = 'market-resolved'
|
||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
|
|||
question: contract.question,
|
||||
outcome,
|
||||
investment: `${Math.floor(investment)}`,
|
||||
payout: `${Math.floor(payout)}${creatorPayoutText}`,
|
||||
payout: `${Math.floor(payout)}`,
|
||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||
unsubscribeUrl,
|
||||
}
|
||||
|
@ -116,7 +116,9 @@ const toDisplayResolution = (
|
|||
}
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const { resolutionValue } = contract
|
||||
const { resolution, resolutionValue } = contract
|
||||
|
||||
if (resolution === 'CANCEL') return 'N/A'
|
||||
|
||||
return resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
|
|
36
functions/src/follow-market.ts
Normal file
36
functions/src/follow-market.ts
Normal 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()
|
||||
}
|
|
@ -30,6 +30,7 @@ export * from './score-contracts'
|
|||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './on-update-contract-follow'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
||||
export const onCreateAnswer = functions.firestore
|
||||
|
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
|
|||
|
||||
const answerCreator = await getUser(answer.userId)
|
||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
answer.id,
|
||||
'answer',
|
||||
'created',
|
||||
answerCreator,
|
||||
eventId,
|
||||
answer.text,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
})
|
||||
|
|
|
@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
|
|||
import { sendNewCommentEmail } from './emails'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
|
|||
const commentCreator = await getUser(comment.userId)
|
||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||
|
||||
await addUserToContractFollowers(contract.id, commentCreator.id)
|
||||
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
|
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
|
|||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||
: answer?.userId
|
||||
|
||||
const recipients = uniq(
|
||||
compact([...parseMentions(comment.content), repliedUserId])
|
||||
)
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
comment.id,
|
||||
'comment',
|
||||
'created',
|
||||
commentCreator,
|
||||
eventId,
|
||||
richTextToString(comment.content),
|
||||
{ contract, relatedSourceType, recipients }
|
||||
contract,
|
||||
{
|
||||
relatedSourceType,
|
||||
repliedUserId,
|
||||
taggedUserIds: compact(parseMentions(comment.content)),
|
||||
}
|
||||
)
|
||||
|
||||
const recipientUserIds = uniq([
|
||||
|
|
|
@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -18,6 +19,7 @@ export const onCreateContract = functions
|
|||
|
||||
const desc = contract.description as JSONContent
|
||||
const mentioned = parseMentions(desc)
|
||||
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser } from './utils'
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { FIXED_ANTE } from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
|
||||
export const onCreateLiquidityProvision = functions.firestore
|
||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||
|
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
const { eventId } = context
|
||||
|
||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||
if (
|
||||
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
|
||||
liquidity.amount === FIXED_ANTE
|
||||
)
|
||||
return
|
||||
|
||||
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
|
||||
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
if (!contract)
|
||||
|
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
|
||||
const liquidityProvider = await getUser(liquidity.userId)
|
||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
|
|
45
functions/src/on-update-contract-follow.ts
Normal file
45
functions/src/on-update-contract-follow.ts
Normal 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),
|
||||
})
|
||||
})
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
||||
export const onUpdateContract = functions.firestore
|
||||
|
@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore
|
|||
resolutionText = `${contract.resolutionValue}`
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'resolved',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
resolutionText,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
} else if (
|
||||
previousValue.closeTime !== contract.closeTime ||
|
||||
|
@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore
|
|||
sourceText = contract.question
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'updated',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
sourceText,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
|
|||
import { floatingEqual } from '../../common/util/math'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { log } from './utils'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
return { betId: betDoc.id, makers, newBet }
|
||||
})
|
||||
|
||||
await addUserToContractFollowers(contractId, auth.uid)
|
||||
|
||||
log('Main transaction finished.')
|
||||
|
||||
if (result.newBet.amount !== 0) {
|
||||
|
|
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
75
functions/src/scripts/backfill-contract-followers.ts
Normal 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)
|
||||
}
|
|
@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
|||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { removeUserFromContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
)
|
||||
|
||||
return { newBet, makers }
|
||||
return { newBet, makers, maxShares, soldShares }
|
||||
})
|
||||
|
||||
if (result.maxShares === result.soldShares) {
|
||||
await removeUserFromContractFollowers(contractId, auth.uid)
|
||||
}
|
||||
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||
log('Share redemption transaction finished.')
|
||||
|
|
|
@ -12,8 +12,8 @@ const firestore = admin.firestore()
|
|||
|
||||
export const updateLoans = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
// Run every Monday.
|
||||
.pubsub.schedule('0 0 * * 1')
|
||||
// Run every day at midnight.
|
||||
.pubsub.schedule('0 0 * * *')
|
||||
.timeZone('America/Los_Angeles')
|
||||
.onRun(updateLoansCore)
|
||||
|
||||
|
@ -79,7 +79,11 @@ async function updateLoansCore() {
|
|||
const today = new Date().toDateString().replace(' ', '-')
|
||||
const key = `loan-notifications-${today}`
|
||||
await Promise.all(
|
||||
userPayouts.map(({ user, payout }) =>
|
||||
userPayouts
|
||||
// Don't send a notification if the payout is < M$1,
|
||||
// because a M$0 loan is confusing.
|
||||
.filter(({ payout }) => payout >= 1)
|
||||
.map(({ user, payout }) =>
|
||||
createLoanIncomeNotification(user, key, payout)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
|
|||
}
|
||||
|
||||
export const updateStats = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 60 minutes')
|
||||
.onRun(updateStatsCore)
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
|||
import clsx from 'clsx'
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||
|
||||
export function NotificationSettings() {
|
||||
const user = useUser()
|
||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
|||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||
useState<notification_subscribe_types>('all')
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
|||
}
|
||||
|
||||
function NotificationSettingLine(props: {
|
||||
label: string
|
||||
label: string | React.ReactNode
|
||||
highlight: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { label, highlight } = props
|
||||
const { label, highlight, onClick } = props
|
||||
return (
|
||||
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
||||
<Row
|
||||
className={clsx(
|
||||
'my-1 gap-1 text-gray-300',
|
||||
highlight && '!text-black',
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||
{label}
|
||||
</Row>
|
||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
|||
toggleClassName={'w-24'}
|
||||
/>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<div>
|
||||
<div className={''}>
|
||||
You will receive notifications for:
|
||||
<NotificationSettingLine
|
||||
label={"Resolution of questions you've interacted with"}
|
||||
highlight={notificationSettings !== 'none'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={'Activity on your own questions, comments, & answers'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Activity on questions you're betting on"}
|
||||
/>
|
||||
<Col className={''}>
|
||||
<Row className={'my-1'}>
|
||||
You will receive notifications for these general events:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<Row className={'my-1'}>
|
||||
You will receive new comment, answer, & resolution notifications on
|
||||
questions:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've ever bet or commented on"}
|
||||
highlight={notificationSettings === 'all'}
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={
|
||||
<span>
|
||||
That <span className={'font-bold'}>you watch </span>- you
|
||||
auto-watch questions if:
|
||||
</span>
|
||||
}
|
||||
onClick={() => setShowModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Col
|
||||
className={clsx(
|
||||
'mb-2 ml-8',
|
||||
'gap-1 text-gray-300',
|
||||
notificationSettings !== 'none' && '!text-black'
|
||||
)}
|
||||
>
|
||||
<Row>• You create it</Row>
|
||||
<Row>• You bet, comment on, or answer it</Row>
|
||||
<Row>• You add liquidity to it</Row>
|
||||
<Row>
|
||||
• If you select 'Less' and you've commented on or answered a
|
||||
question, you'll only receive notification on direct replies to
|
||||
your comments or answers
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</div>
|
||||
<div className={'mt-4'}>Email Notifications</div>
|
||||
<ChoicesToggleGroup
|
||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
|
|||
import { Col } from './layout/col'
|
||||
import { SiteLink } from './site-link'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
export function AmountInput(props: {
|
||||
amount: number | undefined
|
||||
|
@ -33,7 +34,8 @@ export function AmountInput(props: {
|
|||
const isInvalid = !str || isNaN(amount)
|
||||
onChange(isInvalid ? undefined : amount)
|
||||
}
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group mb-4">
|
||||
|
@ -50,6 +52,7 @@ export function AmountInput(props: {
|
|||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
maxLength={6}
|
||||
autoFocus={!isMobile}
|
||||
value={amount ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAmountChange(e.target.value)}
|
||||
|
|
|
@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
const yTickValues = [0, 25, 50, 75, 100]
|
||||
|
||||
const numXTickValues = isLargeWidth ? 5 : 2
|
||||
const hoursAgo = latestTime.subtract(5, 'hours')
|
||||
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
|
||||
? new Date(contract.createdTime)
|
||||
: hoursAgo.toDate()
|
||||
const startDate = new Date(contract.createdTime)
|
||||
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours').toDate()
|
||||
: latestTime.toDate()
|
||||
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
|
||||
|
||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
|
||||
|
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
xScale={{
|
||||
type: 'time',
|
||||
min: startDate,
|
||||
max: latestTime.toDate(),
|
||||
max: endDate,
|
||||
}}
|
||||
xFormat={(d) =>
|
||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
|
||||
format: (time) =>
|
||||
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
|
||||
}}
|
||||
colors={{ scheme: 'pastel1' }}
|
||||
colors={[
|
||||
'#fca5a5', // red-300
|
||||
'#93c5fd', // blue-300
|
||||
'#86efac', // green-300
|
||||
'#f9a8d4', // pink-300
|
||||
'#a5b4fc', // indigo-300
|
||||
'#fcd34d', // amber-300
|
||||
]}
|
||||
pointSize={0}
|
||||
curve="stepAfter"
|
||||
enableSlices="x"
|
||||
|
@ -156,7 +165,11 @@ function formatTime(
|
|||
) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
|
||||
if (
|
||||
d.add(1, 'minute').isAfter(Date.now()) &&
|
||||
d.subtract(1, 'minute').isBefore(Date.now())
|
||||
)
|
||||
return 'Now'
|
||||
|
||||
let format: string
|
||||
if (d.isSame(Date.now(), 'day')) {
|
||||
|
|
|
@ -47,6 +47,7 @@ export function AuthProvider(props: {
|
|||
|
||||
useEffect(() => {
|
||||
return onIdTokenChanged(auth, async (fbUser) => {
|
||||
console.log('onIdTokenChanged', fbUser)
|
||||
if (fbUser) {
|
||||
setTokenCookies({
|
||||
id: await fbUser.getIdToken(),
|
||||
|
|
|
@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
|
|||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { Col } from './layout/col'
|
||||
import { Button } from 'web/components/button'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -30,23 +32,27 @@ export default function BetButton(props: {
|
|||
return (
|
||||
<>
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||
onClick={() => {
|
||||
!user ? firebaseLogin() : setOpen(true)
|
||||
}}
|
||||
>
|
||||
Bet
|
||||
</button>
|
||||
{user ? 'Bet' : 'Sign up to Bet'}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||
{hasYesShares
|
||||
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
|
||||
? `(${Math.floor(yesShares)} ${
|
||||
isPseudoNumeric ? 'HIGHER' : 'YES'
|
||||
})`
|
||||
: hasNoShares
|
||||
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
|
|
|
@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
|
|||
export function BetInline(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
setProbAfter: (probAfter: number) => void
|
||||
setProbAfter: (probAfter: number | undefined) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { contract, className, setProbAfter, onClose } = props
|
||||
|
@ -82,7 +82,7 @@ export function BetInline(props: {
|
|||
<div className="text-xl">Bet</div>
|
||||
<YesNoSelector
|
||||
className="space-x-0"
|
||||
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
selected={outcome}
|
||||
onSelect={setOutcome}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
|
@ -113,7 +113,12 @@ export function BetInline(props: {
|
|||
</Button>
|
||||
)}
|
||||
<SignUpPrompt size="xs" />
|
||||
<button onClick={onClose}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProbAfter(undefined)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<XIcon className="ml-1 h-6 w-6" />
|
||||
</button>
|
||||
</Row>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { clamp, partition, sum, sumBy } from 'lodash'
|
||||
import { clamp, partition, sumBy } from 'lodash'
|
||||
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
|
@ -9,7 +9,6 @@ import { Row } from './layout/row'
|
|||
import { Spacer } from './layout/spacer'
|
||||
import {
|
||||
formatMoney,
|
||||
formatMoneyWithDecimals,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
} from 'common/util/format'
|
||||
|
@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { Bet, LimitBet } from 'common/bet'
|
||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import {
|
||||
BinaryOutcomeLabel,
|
||||
HigherLabel,
|
||||
|
@ -261,8 +259,6 @@ function BuyPanel(props: {
|
|||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = formatPercent(currentReturn)
|
||||
|
||||
const totalFees = sum(Object.values(newBet.fees))
|
||||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
@ -346,9 +342,9 @@ function BuyPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
|
|
@ -91,6 +91,8 @@ export function ContractSearch(props: {
|
|||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
isWholePage?: boolean
|
||||
maxItems?: number
|
||||
noControls?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -105,6 +107,8 @@ export function ContractSearch(props: {
|
|||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
isWholePage,
|
||||
maxItems,
|
||||
noControls,
|
||||
} = props
|
||||
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
|
@ -158,6 +162,8 @@ export function ContractSearch(props: {
|
|||
const contracts = pages
|
||||
.flat()
|
||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||
const renderedContracts =
|
||||
pages.length === 0 ? undefined : contracts.slice(0, maxItems)
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||
|
@ -175,10 +181,11 @@ export function ContractSearch(props: {
|
|||
useQuerySortUrlParams={useQuerySortUrlParams}
|
||||
user={user}
|
||||
onSearchParametersChanged={onSearchParametersChanged}
|
||||
noControls={noControls}
|
||||
/>
|
||||
<ContractsGrid
|
||||
contracts={pages.length === 0 ? undefined : contracts}
|
||||
loadMore={performQuery}
|
||||
contracts={renderedContracts}
|
||||
loadMore={noControls ? undefined : performQuery}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
highlightOptions={highlightOptions}
|
||||
|
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
|
|||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
user?: User | null
|
||||
noControls?: boolean
|
||||
}) {
|
||||
const {
|
||||
className,
|
||||
|
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
|
|||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
user,
|
||||
noControls,
|
||||
} = props
|
||||
|
||||
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
|
||||
|
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
|
|||
})
|
||||
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
|
||||
|
||||
if (noControls) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import Router from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { UserLink } from '../user-page'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
import dayjs from 'dayjs'
|
||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Avatar } from '../avatar'
|
||||
|
@ -21,7 +24,6 @@ import NewContractBadge from '../new-contract-badge'
|
|||
import { UserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
|
@ -30,8 +32,9 @@ import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { insertContent } from '../editor/utils'
|
||||
import clsx from 'clsx'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
import { User } from 'common/user'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -72,6 +75,8 @@ export function MiscDetails(props: {
|
|||
{'Resolved '}
|
||||
{fromNow(resolutionTime || 0)}
|
||||
</Row>
|
||||
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
|
||||
<FeaturedContractBadge />
|
||||
) : volume > 0 || !isNew ? (
|
||||
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
||||
) : (
|
||||
|
@ -134,6 +139,7 @@ export function AbbrContractDetails(props: {
|
|||
export function ContractDetails(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
user: User | null | undefined
|
||||
isCreator?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
@ -157,7 +163,7 @@ export function ContractDetails(props: {
|
|||
)
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar
|
||||
username={creatorUsername}
|
||||
|
@ -179,12 +185,18 @@ export function ContractDetails(props: {
|
|||
<Row>
|
||||
{disabled ? (
|
||||
groupInfo
|
||||
) : !groupToDisplay && !user ? (
|
||||
<div />
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px]'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() =>
|
||||
groupToDisplay
|
||||
? Router.push(groupPath(groupToDisplay.slug))
|
||||
: setOpen(!open)
|
||||
}
|
||||
>
|
||||
{groupInfo}
|
||||
</Button>
|
||||
|
@ -206,10 +218,9 @@ export function ContractDetails(props: {
|
|||
|
||||
{(!!closeTime || !!resolvedDate) && (
|
||||
<Row className="items-center gap-1">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
|
||||
{resolvedDate && contract.resolutionTime ? (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<DateTimeTooltip
|
||||
text="Market resolved:"
|
||||
time={dayjs(contract.resolutionTime)}
|
||||
|
@ -219,8 +230,9 @@ export function ContractDetails(props: {
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!resolvedDate && closeTime && (
|
||||
{!resolvedDate && closeTime && user && (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<EditableCloseDate
|
||||
closeTime={closeTime}
|
||||
contract={contract}
|
||||
|
@ -230,14 +242,15 @@ export function ContractDetails(props: {
|
|||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<Row className="items-center gap-1">
|
||||
<DatabaseIcon className="h-5 w-5" />
|
||||
|
||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Bet } from 'common/bet'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { contractPool } from 'web/lib/firebase/contracts'
|
||||
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
|
@ -16,6 +16,8 @@ import { InfoTooltip } from '../info-tooltip'
|
|||
import { useAdmin, useDev } from 'web/hooks/use-admin'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { firestoreConsolePath } from 'common/envs/constants'
|
||||
import { deleteField } from 'firebase/firestore'
|
||||
import ShortToggle from '../widgets/short-toggle'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -24,6 +26,9 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
const { contract, bets } = props
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [featured, setFeatured] = useState(
|
||||
(contract?.featuredOnHomeRank ?? 0) > 0
|
||||
)
|
||||
const isDev = useDev()
|
||||
const isAdmin = useAdmin()
|
||||
|
||||
|
@ -46,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
? 'Multiple choice'
|
||||
: 'Numeric'
|
||||
|
||||
const onFeaturedToggle = async (enabled: boolean) => {
|
||||
if (
|
||||
enabled &&
|
||||
(contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank)
|
||||
) {
|
||||
await updateContract(id, { featuredOnHomeRank: 1 })
|
||||
setFeatured(true)
|
||||
} else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await updateContract(id, { featuredOnHomeRank: deleteField() })
|
||||
setFeatured(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
|
@ -110,10 +130,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<td>{formatMoney(contract.volume)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
{/* <tr>
|
||||
<td>Creator earnings</td>
|
||||
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
||||
</tr>
|
||||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Traders</td>
|
||||
|
@ -138,6 +158,18 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<tr>
|
||||
<td>[ADMIN] Featured</td>
|
||||
<td>
|
||||
<ShortToggle
|
||||
enabled={featured}
|
||||
setEnabled={setFeatured}
|
||||
onChange={onFeaturedToggle}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -3,7 +3,6 @@ import clsx from 'clsx'
|
|||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { ContractProbGraph } from './contract-prob-graph'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from '../layout/row'
|
||||
|
@ -39,15 +38,15 @@ export const ContractOverview = (props: {
|
|||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
<Col className="gap-4 px-2">
|
||||
<Col className="gap-3 px-2 sm:gap-4">
|
||||
<Row className="justify-between gap-4">
|
||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||
<Linkify text={question} />
|
||||
</div>
|
||||
|
||||
<Row className={'hidden gap-3 xl:flex'}>
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
|
@ -56,35 +55,46 @@ export const ContractOverview = (props: {
|
|||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
{isBinary ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
|
|||
contract={contract}
|
||||
bets={bets}
|
||||
isCreator={isCreator}
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
<Spacer h={4} />
|
||||
<div className={'my-1 md:my-2'}></div>
|
||||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
)}{' '}
|
||||
|
|
|
@ -58,10 +58,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
const { width } = useWindowSize()
|
||||
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const hoursAgo = latestTime.subtract(1, 'hours')
|
||||
const startDate = dayjs(times[0]).isBefore(hoursAgo)
|
||||
? times[0]
|
||||
: hoursAgo.toDate()
|
||||
const startDate = times[0]
|
||||
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours').toDate()
|
||||
: latestTime.toDate()
|
||||
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
|
||||
|
||||
// Minimum number of points for the graph to have. For smooth tooltip movement
|
||||
// On first load, width is undefined, skip adding extra points to let page load faster
|
||||
|
@ -133,14 +134,15 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
xScale={{
|
||||
type: 'time',
|
||||
min: startDate,
|
||||
max: latestTime.toDate(),
|
||||
max: endDate,
|
||||
}}
|
||||
xFormat={(d) =>
|
||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
|
||||
format: (time) =>
|
||||
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
|
||||
}}
|
||||
colors={{ datum: 'color' }}
|
||||
curve="stepAfter"
|
||||
|
@ -183,7 +185,11 @@ function formatTime(
|
|||
) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
|
||||
if (
|
||||
d.add(1, 'minute').isAfter(Date.now()) &&
|
||||
d.subtract(1, 'minute').isBefore(Date.now())
|
||||
)
|
||||
return 'Now'
|
||||
|
||||
let format: string
|
||||
if (d.isSame(Date.now(), 'day')) {
|
||||
|
|
|
@ -86,10 +86,12 @@ export function ContractsGrid(props: {
|
|||
/>
|
||||
))}
|
||||
</Masonry>
|
||||
{loadMore && (
|
||||
<VisibilityObserver
|
||||
onVisibilityUpdated={onVisibilityUpdated}
|
||||
className="relative -top-96 h-1"
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
9
web/components/contract/featured-contract-badge.tsx
Normal file
9
web/components/contract/featured-contract-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
40
web/components/contract/follow-market-modal.tsx
Normal file
40
web/components/contract/follow-market-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -10,6 +10,7 @@ import { User } from 'common/user'
|
|||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { ShareModal } from './share-modal'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||
|
||||
export function ShareRow(props: {
|
||||
contract: Contract
|
||||
|
@ -25,7 +26,7 @@ export function ShareRow(props: {
|
|||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Row className="mt-2">
|
||||
<Row className="mt-0.5 sm:mt-2">
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
|
@ -62,6 +63,7 @@ export function ShareRow(props: {
|
|||
/>
|
||||
</Button>
|
||||
)}
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
77
web/components/follow-market-button.tsx
Normal file
77
web/components/follow-market-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Transition, Dialog } from '@headlessui/react'
|
||||
import { useState, Fragment } from 'react'
|
||||
import Sidebar, { Item } from './sidebar'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
import clsx from 'clsx'
|
||||
|
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
|
|||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
|
@ -44,7 +42,6 @@ export function BottomNavBar() {
|
|||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
|
||||
const isIframe = useIsIframe()
|
||||
if (isIframe) {
|
||||
|
@ -85,11 +82,7 @@ export function BottomNavBar() {
|
|||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||
{privateUser ? (
|
||||
<MoreMenuWithGroupNotifications privateUser={privateUser} />
|
||||
) : (
|
||||
'More'
|
||||
)}
|
||||
More
|
||||
</div>
|
||||
|
||||
<MobileSidebar
|
||||
|
@ -100,22 +93,6 @@ export function BottomNavBar() {
|
|||
)
|
||||
}
|
||||
|
||||
function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) {
|
||||
const { privateUser } = props
|
||||
const preferredNotifications = useUnseenPreferredNotifications(privateUser, {
|
||||
customHref: '/group/',
|
||||
})
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
preferredNotifications.length > 0 ? 'font-bold' : 'font-normal'
|
||||
}
|
||||
>
|
||||
More
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||
const { item, currentPage } = props
|
||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
|
|
|
@ -12,22 +12,20 @@ import {
|
|||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import Router, { useRouter } from 'next/router'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||
import { Group } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { buildArray } from 'common/util/array'
|
||||
|
@ -95,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
href: 'https://salemcenter.manifold.markets/',
|
||||
},
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
|
@ -109,16 +107,16 @@ const signedOutNavigation = [
|
|||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const signedOutMobileNavigation = [
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
|
@ -132,8 +130,8 @@ const signedInMobileNavigation = [
|
|||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
@ -228,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
// usePing(user?.id)
|
||||
|
||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||
const mobileNavigationOptions = !user
|
||||
|
@ -237,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
|
|||
: signedInMobileNavigation
|
||||
|
||||
const memberItems = (
|
||||
useMemberGroups(
|
||||
user?.id,
|
||||
{ withChatEnabled: true },
|
||||
{ by: 'mostRecentChatActivityTime' }
|
||||
) ?? []
|
||||
useMemberGroups(user?.id, undefined, {
|
||||
by: 'mostRecentContractAddedTime',
|
||||
}) ?? []
|
||||
).map((group: Group) => ({
|
||||
name: group.name,
|
||||
href: `${groupPath(group.slug)}`,
|
||||
|
@ -275,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
{memberItems.length > 0 && (
|
||||
<hr className="!my-4 mr-2 border-gray-300" />
|
||||
)}
|
||||
{privateUser && (
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
privateUser={privateUser}
|
||||
/>
|
||||
)}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
|
@ -296,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
|
|||
|
||||
{/* Spacer if there are any groups */}
|
||||
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
||||
{privateUser && (
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
privateUser={privateUser}
|
||||
/>
|
||||
)}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: {
|
||||
currentPage: string
|
||||
memberItems: Item[]
|
||||
privateUser: PrivateUser
|
||||
}) {
|
||||
const { currentPage, memberItems, privateUser } = props
|
||||
const preferredNotifications = useUnseenPreferredNotifications(
|
||||
privateUser,
|
||||
{
|
||||
customHref: '/group/',
|
||||
},
|
||||
memberItems.length > 0 ? memberItems.length : undefined
|
||||
)
|
||||
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||
const { currentPage, memberItems } = props
|
||||
|
||||
const { height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||
|
||||
const notifIsForThisItem = useMemo(
|
||||
() => (itemHref: string) =>
|
||||
preferredNotifications.some(
|
||||
(n) =>
|
||||
!n.isSeen &&
|
||||
(n.isSeenOnHref === itemHref ||
|
||||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
),
|
||||
[preferredNotifications]
|
||||
)
|
||||
// const preferredNotifications = useUnseenPreferredNotifications(
|
||||
// privateUser,
|
||||
// {
|
||||
// customHref: '/group/',
|
||||
// },
|
||||
// memberItems.length > 0 ? memberItems.length : undefined
|
||||
// )
|
||||
// const notifIsForThisItem = useMemo(
|
||||
// () => (itemHref: string) =>
|
||||
// preferredNotifications.some(
|
||||
// (n) =>
|
||||
// !n.isSeen &&
|
||||
// (n.isSeenOnHref === itemHref ||
|
||||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
// ),
|
||||
// [preferredNotifications]
|
||||
// )
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -351,16 +329,12 @@ function GroupsList(props: {
|
|||
>
|
||||
{memberItems.map((item) => (
|
||||
<a
|
||||
href={
|
||||
item.href +
|
||||
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
|
||||
}
|
||||
href={item.href}
|
||||
key={item.name}
|
||||
onClick={trackCallback('sidebar: ' + item.name)}
|
||||
onClick={trackCallback('click sidebar group', { name: item.name })}
|
||||
className={clsx(
|
||||
'cursor-pointer truncate',
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||
notifIsForThisItem(item.href) && 'font-bold'
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
@ -11,11 +11,12 @@ export function LoansModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🏦</span>
|
||||
<span className="text-xl">Loans on your bets</span>
|
||||
<span className="text-xl">Daily loans on your bets</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are loans?</span>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
Every Monday, get 5% of your total bet amount back as a loan.
|
||||
Every day at midnight PT, get 1% of your total bet amount back as a
|
||||
loan.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• Do I have to pay back a loan?
|
||||
|
@ -33,12 +34,12 @@ export function LoansModal(props: {
|
|||
</span>
|
||||
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||
<span className={'ml-2'}>
|
||||
For example, if you bet M$100 on "Will I become a millionare?" on
|
||||
Sunday, you will get M$5 back on Monday.
|
||||
For example, if you bet M$1000 on "Will I become a millionare?" on
|
||||
Monday, you will get M$10 back on Tuesday.
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Previous loans count against your total bet amount. So, the next
|
||||
week, you would get back 5% of M$95 = M$4.75.
|
||||
Previous loans count against your total bet amount. So on Wednesday,
|
||||
you would get back 1% of M$990 = M$9.9.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
|
|
|
@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
|
|||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { BinaryContract, resolution } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function ResolutionPanel(props: {
|
||||
creator: User
|
||||
|
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
|
|||
}) {
|
||||
const { contract, className } = props
|
||||
|
||||
const earnedFees =
|
||||
contract.mechanism === 'dpm-2'
|
||||
? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
||||
: `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
||||
// const earnedFees =
|
||||
// contract.mechanism === 'dpm-2'
|
||||
// ? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
||||
// : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
||||
|
||||
const [outcome, setOutcome] = useState<resolution | undefined>()
|
||||
|
||||
|
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
|
|||
{outcome === 'YES' ? (
|
||||
<>
|
||||
Winnings will be paid out to YES bettors.
|
||||
{/* <br />
|
||||
<br />
|
||||
<br />
|
||||
You will earn {earnedFees}.
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'NO' ? (
|
||||
<>
|
||||
Winnings will be paid out to NO bettors.
|
||||
{/* <br />
|
||||
<br />
|
||||
<br />
|
||||
You will earn {earnedFees}.
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'CANCEL' ? (
|
||||
<>All trades will be returned with no fees.</>
|
||||
|
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
|
|||
probabilityInt={Math.round(prob)}
|
||||
setProbabilityInt={setProb}
|
||||
/>
|
||||
You will earn {earnedFees}.
|
||||
{/* You will earn {earnedFees}. */}
|
||||
</Col>
|
||||
) : (
|
||||
<>Resolving this market will immediately pay out traders.</>
|
||||
|
|
96
web/components/twitch-panel.tsx
Normal file
96
web/components/twitch-panel.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -5,13 +5,19 @@ import clsx from 'clsx'
|
|||
export default function ShortToggle(props: {
|
||||
enabled: boolean
|
||||
setEnabled: (enabled: boolean) => void
|
||||
onChange?: (enabled: boolean) => void
|
||||
}) {
|
||||
const { enabled, setEnabled } = props
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
onChange={(e: boolean) => {
|
||||
setEnabled(e)
|
||||
if (props.onChange) {
|
||||
props.onChange(e)
|
||||
}
|
||||
}}
|
||||
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
||||
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useFollows = (userId: string | null | undefined) => {
|
||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||
|
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
|
|||
|
||||
return followerIds
|
||||
}
|
||||
|
||||
export const useContractFollows = (contractId: string) => {
|
||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
return listenForContractFollows(contractId, setFollowIds)
|
||||
}, [contractId])
|
||||
|
||||
return followIds
|
||||
}
|
||||
|
|
|
@ -147,6 +147,7 @@ export function useUnseenPreferredNotifications(
|
|||
const lessPriorityReasons = [
|
||||
'on_contract_with_users_comment',
|
||||
'on_contract_with_users_answer',
|
||||
// Notifications not currently generated for users who've sold their shares
|
||||
'on_contract_with_users_shares_out',
|
||||
// Not sure if users will want to see these w/ less:
|
||||
// 'on_contract_with_users_shares_in',
|
||||
|
|
49
web/hooks/use-redirect-after-signup.ts
Normal file
49
web/hooks/use-redirect-after-signup.ts
Normal 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
9
web/lib/api/api-key.ts
Normal 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)
|
||||
}
|
|
@ -212,6 +212,29 @@ export function listenForContract(
|
|||
return listenForValue<Contract>(contractRef, setContract)
|
||||
}
|
||||
|
||||
export function listenForContractFollows(
|
||||
contractId: string,
|
||||
setFollowIds: (followIds: string[]) => void
|
||||
) {
|
||||
const follows = collection(contracts, contractId, 'follows')
|
||||
return listenForValues<{ id: string }>(follows, (docs) =>
|
||||
setFollowIds(docs.map(({ id }) => id))
|
||||
)
|
||||
}
|
||||
|
||||
export async function followContract(contractId: string, userId: string) {
|
||||
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
|
||||
return await setDoc(followDoc, {
|
||||
id: userId,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unFollowContract(contractId: string, userId: string) {
|
||||
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
|
||||
await deleteDoc(followDoc)
|
||||
}
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
|
@ -272,6 +295,26 @@ export async function getClosingSoonContracts() {
|
|||
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||
}
|
||||
|
||||
export const getRandTopCreatorContracts = async (
|
||||
creatorId: string,
|
||||
count: number,
|
||||
excluding: string[] = []
|
||||
) => {
|
||||
const creatorContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('creatorId', '==', creatorId),
|
||||
orderBy('popularityScore', 'desc'),
|
||||
limit(Math.max(count * 2, 15))
|
||||
)
|
||||
const data = await getValues<Contract>(creatorContractsQuery)
|
||||
const open = data
|
||||
.filter((c) => c.closeTime && c.closeTime > Date.now())
|
||||
.filter((c) => !excluding.includes(c.id))
|
||||
|
||||
return chooseRandomSubset(open, count)
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = doc(contracts, contract.id)
|
||||
|
||||
|
|
47
web/lib/twitch/link-twitch-account.ts
Normal file
47
web/lib/twitch/link-twitch-account.ts
Normal 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}'`)
|
||||
}
|
|
@ -27,14 +27,14 @@
|
|||
"@nivo/line": "0.74.0",
|
||||
"@nivo/tooltip": "0.74.0",
|
||||
"@react-query-firebase/firestore": "0.4.2",
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/extension-placeholder": "2.0.0-beta.53",
|
||||
"@tiptap/react": "2.0.0-beta.114",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"algoliasearch": "4.13.0",
|
||||
"browser-image-compression": "2.0.0",
|
||||
"clsx": "1.1.1",
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||
import { BetPanel } from 'web/components/bet-panel'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ResolutionPanel } from 'web/components/resolution-panel'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import {
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
getRandTopCreatorContracts,
|
||||
tradingAllowed,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
|
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
|
|||
import Custom404 from '../404'
|
||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ContractTabs } from 'web/components/contract/contract-tabs'
|
||||
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
|
||||
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
|
||||
|
@ -34,15 +31,17 @@ import { useBets } from 'web/hooks/use-bets'
|
|||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { User } from 'common/user'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { listUsers } from 'web/lib/firebase/users'
|
||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||
import { Title } from 'web/components/title'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { getOpenGraphProps } from 'common/contract-details'
|
||||
import {
|
||||
ContractLeaderboard,
|
||||
ContractTopTrades,
|
||||
} from 'web/components/contract/contract-leaderboard'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
|
|||
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||
const contractId = contract?.id
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
const [bets, comments, recommendedContracts] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contract
|
||||
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
|
||||
: [],
|
||||
])
|
||||
|
||||
return {
|
||||
|
@ -65,6 +67,7 @@ export async function getStaticPropz(props: {
|
|||
// Limit the data sent to the client. Client will still load all bets and comments directly.
|
||||
bets: bets.slice(0, 5000),
|
||||
comments: comments.slice(0, 1000),
|
||||
recommendedContracts,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -81,6 +84,7 @@ export default function ContractPage(props: {
|
|||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
slug: string
|
||||
recommendedContracts: Contract[]
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
|
@ -88,6 +92,7 @@ export default function ContractPage(props: {
|
|||
username: '',
|
||||
comments: [],
|
||||
bets: [],
|
||||
recommendedContracts: [],
|
||||
slug: '',
|
||||
}
|
||||
|
||||
|
@ -149,7 +154,7 @@ export function ContractPageContent(
|
|||
user?: User | null
|
||||
}
|
||||
) {
|
||||
const { backToHome, comments, user } = props
|
||||
const { backToHome, comments, user, recommendedContracts } = props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
|
@ -263,128 +268,13 @@ export function ContractPageContent(
|
|||
comments={comments}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{recommendedContracts.length > 0 && (
|
||||
<Col className="gap-2 px-2 sm:px-0">
|
||||
<Subtitle text="Recommended" />
|
||||
<ContractsGrid contracts={recommendedContracts} />
|
||||
</Col>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
const [users, setUsers] = useState<User[]>()
|
||||
|
||||
const { userProfits, top5Ids } = useMemo(() => {
|
||||
// Create a map of userIds to total profits (including sales)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const betsByUser = groupBy(openBets, 'userId')
|
||||
|
||||
const userProfits = mapValues(betsByUser, (bets) =>
|
||||
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||
)
|
||||
// Find the 5 users with the most profits
|
||||
const top5Ids = Object.entries(userProfits)
|
||||
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
|
||||
.filter(([, p]) => p > 0)
|
||||
.slice(0, 5)
|
||||
.map(([id]) => id)
|
||||
return { userProfits, top5Ids }
|
||||
}, [contract, bets])
|
||||
|
||||
useEffect(() => {
|
||||
if (top5Ids.length > 0) {
|
||||
listUsers(top5Ids).then((users) => {
|
||||
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||
setUsers(sortedUsers)
|
||||
})
|
||||
}
|
||||
}, [userProfits, top5Ids])
|
||||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
|
||||
},
|
||||
]}
|
||||
className="mt-12 max-w-sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = useUserById(betsById[topBetId]?.userId)
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
|
||||
return (
|
||||
<div className="mt-12 max-w-sm">
|
||||
{topCommentId && profitById[topCommentId] > 0 && (
|
||||
<>
|
||||
<Title text="💬 Proven correct" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedComment
|
||||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
tips={tips[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{commentsById[topCommentId].userName} made{' '}
|
||||
{formatMoney(profitById[topCommentId] || 0)}!
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Smartest money" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedBet
|
||||
contract={contract}
|
||||
bet={betsById[topBetId]}
|
||||
hideOutcome={false}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
|
|||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { FIXED_ANTE } from 'common/economy'
|
||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy'
|
||||
import { InfoTooltip } from 'web/components/info-tooltip'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
@ -158,6 +158,8 @@ export function NewContract(props: {
|
|||
: undefined
|
||||
|
||||
const balance = creator.balance || 0
|
||||
const deservesFreeMarket =
|
||||
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||
|
||||
const min = minString ? parseFloat(minString) : undefined
|
||||
const max = maxString ? parseFloat(maxString) : undefined
|
||||
|
@ -177,7 +179,7 @@ export function NewContract(props: {
|
|||
question.length > 0 &&
|
||||
ante !== undefined &&
|
||||
ante !== null &&
|
||||
ante <= balance &&
|
||||
(ante <= balance || deservesFreeMarket) &&
|
||||
// closeTime must be in the future
|
||||
closeTime &&
|
||||
closeTime > Date.now() &&
|
||||
|
@ -461,12 +463,25 @@ export function NewContract(props: {
|
|||
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!deservesFreeMarket ? (
|
||||
<div className="label-text text-neutral pl-1">
|
||||
{formatMoney(ante)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label-text text-primary pl-1">
|
||||
FREE{' '}
|
||||
<span className="label-text pl-1 text-gray-500">
|
||||
(You have{' '}
|
||||
{FREE_MARKETS_PER_USER_MAX -
|
||||
(creator?.freeMarketsCreated ?? 0)}{' '}
|
||||
free markets left)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ante > balance && (
|
||||
{ante > balance && !deservesFreeMarket && (
|
||||
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
|
||||
<span className="mr-2 text-red-500">Insufficient balance</span>
|
||||
<button
|
||||
|
|
|
@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
contract={contract}
|
||||
bets={bets}
|
||||
isCreator={false}
|
||||
user={null}
|
||||
disabled
|
||||
/>
|
||||
|
||||
|
@ -165,7 +166,8 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<AnswersGraph contract={contract} bets={bets} height={graphHeight} />
|
||||
)}
|
||||
|
||||
|
|
118
web/pages/experimental/home.tsx
Normal file
118
web/pages/experimental/home.tsx
Normal 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
|
|
@ -33,11 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
||||
import { ContractSearch } from 'web/components/contract-search'
|
||||
import { FollowList } from 'web/components/follow-list'
|
||||
import { SearchIcon } from '@heroicons/react/outline'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||
|
@ -46,7 +44,6 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
|
|||
import { Button } from 'web/components/button'
|
||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||
import { GroupComment } from 'common/comment'
|
||||
import { GroupChat } from 'web/components/groups/group-chat'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
|
@ -149,9 +146,6 @@ export default function GroupPage(props: {
|
|||
const page = slugs?.[1] as typeof groupSubpages[number]
|
||||
|
||||
const group = useGroup(props.group?.id) ?? props.group
|
||||
const tips = useTipTxns({ groupId: group?.id })
|
||||
|
||||
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
||||
|
||||
const user = useUser()
|
||||
|
||||
|
@ -201,21 +195,12 @@ export default function GroupPage(props: {
|
|||
/>
|
||||
)
|
||||
|
||||
const chatTab = (
|
||||
<GroupChat messages={messages} group={group} user={user} tips={tips} />
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Markets',
|
||||
content: questionsTab,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
content: chatTab,
|
||||
href: groupPath(group.slug, 'chat'),
|
||||
},
|
||||
{
|
||||
title: 'Leaderboards',
|
||||
content: leaderboard,
|
||||
|
|
|
@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
backToHome={() => {
|
||||
history.back()
|
||||
}}
|
||||
recommendedContracts={[]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -44,6 +44,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { NotificationSettings } from 'web/components/NotificationSettings'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
||||
export const NOTIFICATIONS_PER_PAGE = 30
|
||||
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
||||
|
@ -199,7 +200,9 @@ function IncomeNotificationGroupItem(props: {
|
|||
const { notificationGroup, className } = props
|
||||
const { notifications } = notificationGroup
|
||||
const numSummaryLines = 3
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expanded, setExpanded] = useState(
|
||||
notifications.length <= numSummaryLines
|
||||
)
|
||||
const [highlighted, setHighlighted] = useState(
|
||||
notifications.some((n) => !n.isSeen)
|
||||
)
|
||||
|
@ -378,6 +381,8 @@ function IncomeNotificationItem(props: {
|
|||
const [highlighted] = useState(!notification.isSeen)
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width && width < 768) || false
|
||||
const user = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
@ -397,16 +402,16 @@ function IncomeNotificationItem(props: {
|
|||
} else if (sourceType === 'betting_streak_bonus') {
|
||||
reasonText = 'for your'
|
||||
} else if (sourceType === 'loan' && sourceText) {
|
||||
reasonText = `of your invested bets returned as`
|
||||
reasonText = `of your invested bets returned as a`
|
||||
}
|
||||
|
||||
const streakInDays =
|
||||
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
||||
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
||||
: user?.currentBettingStreak ?? 0
|
||||
const bettingStreakText =
|
||||
sourceType === 'betting_streak_bonus' &&
|
||||
(sourceText
|
||||
? `🔥 ${
|
||||
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
|
||||
} day Betting Streak`
|
||||
: 'Betting Streak')
|
||||
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -521,7 +526,7 @@ function IncomeNotificationItem(props: {
|
|||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
<div className={'mt-4 border-b border-gray-300'} />
|
||||
<div className={'border-b border-gray-300 pt-4'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -538,7 +543,9 @@ function NotificationGroupItem(props: {
|
|||
const isMobile = (width && width < 768) || false
|
||||
const numSummaryLines = 3
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expanded, setExpanded] = useState(
|
||||
notifications.length <= numSummaryLines
|
||||
)
|
||||
const [highlighted, setHighlighted] = useState(
|
||||
notifications.some((n) => !n.isSeen)
|
||||
)
|
||||
|
|
|
@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { User, PrivateUser } from 'common/user'
|
||||
import {
|
||||
getUserAndPrivateUser,
|
||||
updateUser,
|
||||
updatePrivateUser,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { TwitchPanel } from 'web/components/twitch-panel'
|
||||
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
|
||||
|
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
|
|||
}
|
||||
|
||||
const updateApiKey = async (e: React.MouseEvent) => {
|
||||
const newApiKey = crypto.randomUUID()
|
||||
setApiKey(newApiKey)
|
||||
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
|
||||
setApiKey(privateUser.apiKey || '')
|
||||
})
|
||||
const newApiKey = await generateNewApiKey(user.id)
|
||||
setApiKey(newApiKey ?? '')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
|
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwitchPanel />
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
115
web/pages/twitch.tsx
Normal file
115
web/pages/twitch.tsx
Normal 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
BIN
web/public/twitch-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
18
yarn.lock
18
yarn.lock
|
@ -2919,10 +2919,10 @@
|
|||
lodash.isplainobject "^4.0.6"
|
||||
lodash.merge "^4.6.2"
|
||||
|
||||
"@tiptap/core@2.0.0-beta.181", "@tiptap/core@^2.0.0-beta.181":
|
||||
version "2.0.0-beta.181"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.181.tgz#07aeea26336814ab82eb7f4199b17538187c6fbb"
|
||||
integrity sha512-tbwRqjTVvY9v31TNAH6W0Njhr/OVwI28zWXmH55/USrwyU2CB1iCVfXktZKOhB+8WyvOaBv1JA5YplMIhstYTw==
|
||||
"@tiptap/core@2.0.0-beta.182", "@tiptap/core@^2.0.0-beta.182":
|
||||
version "2.0.0-beta.182"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.182.tgz#d2001e9b765adda95e15d171479860a3349e2d04"
|
||||
integrity sha512-MZGkMGnVnWhBzjvpBNwQ9zBz38ndi3Irbf90uCTSArR0kaCVkW4vmyuPuOXd+0SO8Yv/l5oyDdOCpaG3rnQYfw==
|
||||
dependencies:
|
||||
prosemirror-commands "1.3.0"
|
||||
prosemirror-keymap "1.2.0"
|
||||
|
@ -3099,12 +3099,12 @@
|
|||
"@tiptap/extension-floating-menu" "^2.0.0-beta.56"
|
||||
prosemirror-view "1.26.2"
|
||||
|
||||
"@tiptap/starter-kit@2.0.0-beta.190":
|
||||
version "2.0.0-beta.190"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.190.tgz#fe0021e29d070fc5707722513a398c8884e15f71"
|
||||
integrity sha512-jaFMkE6mjCHmCJsXUyLiXGYRVDcHF+PbH/5hEu1riUIAT0Hmm7uak5TYsPeuoCVN7P/tmDEBbBRASZ5CzEQpvw==
|
||||
"@tiptap/starter-kit@2.0.0-beta.191":
|
||||
version "2.0.0-beta.191"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.191.tgz#3f549367f6dbb8cf83f63aa0941722d91d0fd8e7"
|
||||
integrity sha512-YRrBCi9W4jiH/xLTJJOCdD7pL4Wb98Ip8qCJ94RElShDj0O1i5tT9wWlgVWoGIU+CRAds5XENRwZ97sJ+YfYyg==
|
||||
dependencies:
|
||||
"@tiptap/core" "^2.0.0-beta.181"
|
||||
"@tiptap/core" "^2.0.0-beta.182"
|
||||
"@tiptap/extension-blockquote" "^2.0.0-beta.29"
|
||||
"@tiptap/extension-bold" "^2.0.0-beta.28"
|
||||
"@tiptap/extension-bullet-list" "^2.0.0-beta.29"
|
||||
|
|
Loading…
Reference in New Issue
Block a user