Merge branch 'main' into limit-orders
This commit is contained in:
commit
a2a655063a
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['lib'],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
|
@ -31,6 +32,7 @@ module.exports = {
|
|||
rules: {
|
||||
'no-extra-semi': 'off',
|
||||
'no-constant-condition': ['error', { checkLoops: false }],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -10,14 +10,12 @@ import {
|
|||
import { User } from './user'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { noFees } from './fees'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
export const FIXED_ANTE = 100
|
||||
|
||||
// deprecated
|
||||
export const PHANTOM_ANTE = 0.001
|
||||
export const MINIMUM_ANTE = 50
|
||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||
|
||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
||||
|
||||
export function getCpmmInitialLiquidity(
|
||||
providerId: string,
|
||||
|
|
|
@ -18,13 +18,17 @@ export type EnvConfig = {
|
|||
faviconPath?: string // Should be a file in /public
|
||||
navbarLogoPath?: string
|
||||
newQuestionPlaceholders: string[]
|
||||
|
||||
// Currency controls
|
||||
fixedAnte?: number
|
||||
startingBalance?: number
|
||||
}
|
||||
|
||||
type FirebaseConfig = {
|
||||
apiKey: string
|
||||
authDomain: string
|
||||
projectId: string
|
||||
region: string
|
||||
region?: string
|
||||
storageBucket: string
|
||||
messagingSenderId: string
|
||||
appId: string
|
||||
|
|
|
@ -30,9 +30,9 @@ import {
|
|||
floatingLesserEqual,
|
||||
} from './util/math'
|
||||
|
||||
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
|
||||
export type BetInfo = {
|
||||
newBet: CandidateBet<Bet>
|
||||
newBet: CandidateBet
|
||||
newPool?: { [outcome: string]: number }
|
||||
newTotalShares?: { [outcome: string]: number }
|
||||
newTotalBets?: { [outcome: string]: number }
|
||||
|
@ -209,7 +209,7 @@ export const getBinaryCpmmBetInfo = (
|
|||
const takerShares = sumBy(takers, 'shares')
|
||||
const isFilled = floatingEqual(betAmount, takerAmount)
|
||||
|
||||
const newBet = removeUndefinedProps({
|
||||
const newBet: CandidateBet = removeUndefinedProps({
|
||||
amount: betAmount,
|
||||
limitProb,
|
||||
isFilled,
|
||||
|
@ -269,7 +269,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
const probBefore = getDpmProbability(contract.totalShares)
|
||||
const probAfter = getDpmProbability(newTotalShares)
|
||||
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
|
@ -306,7 +306,7 @@ export const getNewMultiBetInfo = (
|
|||
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
|
||||
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
|
||||
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
|
|
|
@ -22,6 +22,8 @@ export type Notification = {
|
|||
|
||||
sourceSlug?: string
|
||||
sourceTitle?: string
|
||||
|
||||
isSeenOnHref?: string
|
||||
}
|
||||
export type notification_source_types =
|
||||
| 'contract'
|
||||
|
@ -34,6 +36,7 @@ export type notification_source_types =
|
|||
| 'admin_message'
|
||||
| 'group'
|
||||
| 'user'
|
||||
| 'bonus'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -56,3 +59,6 @@ export type notification_reason_types =
|
|||
| 'added_you_to_group'
|
||||
| 'you_referred_user'
|
||||
| 'user_joined_to_bet_on_your_market'
|
||||
| 'unique_bettors_on_your_contract'
|
||||
| 'on_group_you_are_member_of'
|
||||
| 'tip_received'
|
||||
|
|
|
@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
|
|||
|
||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
|
||||
|
|
|
@ -17,7 +17,7 @@ export const getMappedValue =
|
|||
|
||||
if (isLogScale) {
|
||||
const logValue = p * Math.log10(max - min)
|
||||
return 10 ** logValue + min
|
||||
return 10 ** logValue + min
|
||||
}
|
||||
|
||||
return p * (max - min) + min
|
||||
|
|
54
common/redeem.ts
Normal file
54
common/redeem.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { partition, sumBy } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getProbability } from './calculate'
|
||||
import { CPMMContract } from './contract'
|
||||
import { noFees } from './fees'
|
||||
import { CandidateBet } from './new-bet'
|
||||
|
||||
type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'>
|
||||
|
||||
export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
||||
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
|
||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPayment = Math.min(loanAmount, shares)
|
||||
const netAmount = shares - loanPayment
|
||||
return { shares, loanPayment, netAmount }
|
||||
}
|
||||
|
||||
export const getRedemptionBets = (
|
||||
shares: number,
|
||||
loanPayment: number,
|
||||
contract: CPMMContract
|
||||
) => {
|
||||
const p = getProbability(contract)
|
||||
const createdTime = Date.now()
|
||||
const yesBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount: p * -shares,
|
||||
shares: -shares,
|
||||
loanAmount: loanPayment ? -loanPayment / 2 : 0,
|
||||
outcome: 'YES',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
const noBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount: (1 - p) * -shares,
|
||||
shares: -shares,
|
||||
loanAmount: loanPayment ? -loanPayment / 2 : 0,
|
||||
outcome: 'NO',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
return [yesBet, noBet]
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||
type AnyTxnType = Donation | Tip | Manalink | Referral
|
||||
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET'
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
||||
|
@ -52,6 +53,12 @@ type Referral = {
|
|||
category: 'REFERRAL'
|
||||
}
|
||||
|
||||
type Bonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
@ -38,8 +40,9 @@ export type User = {
|
|||
referredByContractId?: string
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = 1000
|
||||
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||
export const REFERRAL_AMOUNT = 500
|
||||
export type PrivateUser = {
|
||||
id: string // same as User.id
|
||||
|
@ -54,6 +57,7 @@ export type PrivateUser = {
|
|||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
lastTimeCheckedBonuses?: number
|
||||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
|
|
@ -337,6 +337,20 @@
|
|||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "portfolioHistory",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
|
|
|
@ -21,16 +21,15 @@ service cloud.firestore {
|
|||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['referredByUserId'])
|
||||
// only one referral allowed per user
|
||||
&& !("referredByUserId" in resource.data)
|
||||
// user can't refer themselves
|
||||
&& (resource.data.id != request.resource.data.referredByUserId)
|
||||
// user can't refer someone who referred them quid pro quo
|
||||
&& get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id;
|
||||
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['referredByUserId'])
|
||||
// only one referral allowed per user
|
||||
&& !("referredByUserId" in resource.data)
|
||||
// user can't refer themselves
|
||||
&& !(resource.data.id == request.resource.data.referredByUserId);
|
||||
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
|
||||
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
|
||||
}
|
||||
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
|
|
3
functions/.env
Normal file
3
functions/.env
Normal file
|
@ -0,0 +1,3 @@
|
|||
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_ENV=PROD
|
|
@ -1,7 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['lib'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
|
@ -30,6 +30,7 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
rules: {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
}
|
||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -1,5 +1,4 @@
|
|||
# Secrets
|
||||
.env*
|
||||
.runtimeconfig.json
|
||||
|
||||
# GCP deployment artifact
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
|
@ -23,6 +23,7 @@
|
|||
"main": "functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
|
|
|
@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
|||
const firestore = admin.firestore()
|
||||
|
||||
type user_to_reason_texts = {
|
||||
[userId: string]: { reason: notification_reason_types }
|
||||
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
|
||||
}
|
||||
|
||||
export const createNotification = async (
|
||||
|
@ -66,12 +66,11 @@ export const createNotification = async (
|
|||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||
sourceText,
|
||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||
// TODO: move away from sourceContractTitle to sourceTitle
|
||||
sourceContractTitle: sourceContract?.question,
|
||||
// TODO: move away from sourceContractSlug to sourceSlug
|
||||
sourceContractSlug: sourceContract?.slug,
|
||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
|
@ -267,6 +266,35 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'unique_bettors_on_your_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherGroupMembersOfComment = async (
|
||||
userToReasons: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
if (shouldGetNotification(userId, userToReasons))
|
||||
userToReasons[userId] = {
|
||||
reason: 'on_group_you_are_member_of',
|
||||
isSeeOnHref: sourceSlug,
|
||||
}
|
||||
}
|
||||
const notifyTippedUserOfNewTip = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'tip_received',
|
||||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
@ -277,10 +305,13 @@ export const createNotification = async (
|
|||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'user' && relatedUserId) {
|
||||
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'comment' && !sourceContract && relatedUserId) {
|
||||
await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId)
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
if (!sourceContract) return userToReasonTexts
|
||||
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
|
@ -309,6 +340,14 @@ export const createNotification = async (
|
|||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
} else if (sourceType === 'tip' && relatedUserId) {
|
||||
await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId)
|
||||
}
|
||||
return userToReasonTexts
|
||||
}
|
||||
|
|
142
functions/src/get-daily-bonuses.ts
Normal file
142
functions/src/get-daily-bonuses.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { isProd, log } from './utils'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { PrivateUser } from '../../common/lib/user'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet } from '../../common/lib/bet'
|
||||
const firestore = admin.firestore()
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import { createNotification } from './create-notification'
|
||||
import { User } from '../../common/lib/user'
|
||||
import { Contract } from '../../common/lib/contract'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
|
||||
|
||||
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
|
||||
const QUERY_LIMIT_SECONDS = 60
|
||||
|
||||
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
|
||||
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
|
||||
async (trans) => {
|
||||
const userSnap = await trans.get(
|
||||
firestore.doc(`private-users/${auth.uid}`)
|
||||
)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const user = userSnap.data() as PrivateUser
|
||||
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
|
||||
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
|
||||
throw new APIError(
|
||||
400,
|
||||
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
|
||||
)
|
||||
await trans.update(userSnap.ref, {
|
||||
lastTimeCheckedBonuses: Date.now(),
|
||||
})
|
||||
return {
|
||||
user,
|
||||
lastTimeCheckedBonuses,
|
||||
}
|
||||
}
|
||||
)
|
||||
const fromUserId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||
const fromUser = fromSnap.data() as User
|
||||
// Get all users contracts made since implementation time
|
||||
const userContractsSnap = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>=', BONUS_START_DATE)
|
||||
.get()
|
||||
const userContracts = userContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
)
|
||||
const nullReturn = { status: 'no bets', txn: null }
|
||||
for (const contract of userContracts) {
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const contractId = contract.id
|
||||
// Get all bets made on user's contracts
|
||||
const bets = (
|
||||
await firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.where('userId', '!=', user.id)
|
||||
.get()
|
||||
).docs.map((bet) => bet.ref)
|
||||
if (bets.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
const contractBetsSnap = await trans.getAll(...bets)
|
||||
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
|
||||
|
||||
const uniqueBettorIdsBeforeLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter users for ONLY those that have made bets since the last daily bonus received time
|
||||
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter for users only present in the above list
|
||||
const newUniqueBettorIds =
|
||||
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
|
||||
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
|
||||
)
|
||||
newUniqueBettorIds.length > 0 &&
|
||||
log(
|
||||
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
|
||||
)
|
||||
if (newUniqueBettorIds.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
// Create combined txn for all unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contractId,
|
||||
uniqueBettors: newUniqueBettorIds.length,
|
||||
}
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
fromType: 'BANK',
|
||||
toId: user.id,
|
||||
toType: 'USER',
|
||||
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
|
||||
token: 'M$',
|
||||
category: 'UNIQUE_BETTOR_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
result.status != nullReturn.status &&
|
||||
log(`No bonus for user: ${user.id} - reason:`, result.status)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
|
||||
await createNotification(
|
||||
result.txn.id,
|
||||
'bonus',
|
||||
'created',
|
||||
fromUser,
|
||||
result.txn.id,
|
||||
result.txn.amount + '',
|
||||
contract,
|
||||
undefined,
|
||||
// No need to set the user id, we'll use the contract creator id
|
||||
undefined,
|
||||
contract.slug,
|
||||
contract.question
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { userId: user.id, message: 'success' }
|
||||
})
|
|
@ -10,7 +10,7 @@ export * from './stripe'
|
|||
export * from './create-user'
|
||||
export * from './create-answer'
|
||||
export * from './on-create-bet'
|
||||
export * from './on-create-comment'
|
||||
export * from './on-create-comment-on-contract'
|
||||
export * from './on-view'
|
||||
export * from './unsubscribe'
|
||||
export * from './update-metrics'
|
||||
|
@ -28,6 +28,8 @@ export * from './on-create-liquidity-provision'
|
|||
export * from './on-update-group'
|
||||
export * from './on-create-group'
|
||||
export * from './on-update-user'
|
||||
export * from './on-create-comment-on-group'
|
||||
export * from './on-create-txn'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -39,3 +41,4 @@ export * from './create-contract'
|
|||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './get-daily-bonuses'
|
||||
|
|
|
@ -11,7 +11,7 @@ import { createNotification } from './create-notification'
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onCreateComment = functions
|
||||
export const onCreateCommentOnContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('contracts/{contractId}/comments/{commentId}')
|
||||
.onCreate(async (change, context) => {
|
52
functions/src/on-create-comment-on-group.ts
Normal file
52
functions/src/on-create-comment-on-group.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { Comment } from '../../common/comment'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { User } from '../../common/user'
|
||||
import { createNotification } from './create-notification'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onCreateCommentOnGroup = functions.firestore
|
||||
.document('groups/{groupId}/comments/{commentId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { eventId } = context
|
||||
const { groupId } = context.params as {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const creatorSnapshot = await firestore
|
||||
.collection('users')
|
||||
.doc(comment.userId)
|
||||
.get()
|
||||
if (!creatorSnapshot.exists) throw new Error('Could not find user')
|
||||
|
||||
const groupSnapshot = await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.get()
|
||||
if (!groupSnapshot.exists) throw new Error('Could not find group')
|
||||
|
||||
const group = groupSnapshot.data() as Group
|
||||
await firestore.collection('groups').doc(groupId).update({
|
||||
mostRecentActivityTime: comment.createdTime,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
group.memberIds.map(async (memberId) => {
|
||||
return await createNotification(
|
||||
comment.id,
|
||||
'comment',
|
||||
'created',
|
||||
creatorSnapshot.data() as User,
|
||||
eventId,
|
||||
comment.text,
|
||||
undefined,
|
||||
undefined,
|
||||
memberId,
|
||||
`/group/${group.slug}`,
|
||||
`${group.name}`
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
68
functions/src/on-create-txn.ts
Normal file
68
functions/src/on-create-txn.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { Txn } from 'common/txn'
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Comment } from 'common/comment'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onCreateTxn = functions.firestore
|
||||
.document('txns/{txnId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const txn = change.data() as Txn
|
||||
const { eventId } = context
|
||||
|
||||
if (txn.category === 'TIP') {
|
||||
await handleTipTxn(txn, eventId)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleTipTxn(txn: Txn, eventId: string) {
|
||||
// get user sending and receiving tip
|
||||
const [sender, receiver] = await Promise.all([
|
||||
getUser(txn.fromId),
|
||||
getUser(txn.toId),
|
||||
])
|
||||
if (!sender || !receiver) {
|
||||
log('Could not find corresponding users')
|
||||
return
|
||||
}
|
||||
|
||||
if (!txn.data?.contractId || !txn.data?.commentId) {
|
||||
log('No contractId or comment id in tip txn.data')
|
||||
return
|
||||
}
|
||||
|
||||
const contract = await getContract(txn.data.contractId)
|
||||
if (!contract) {
|
||||
log('Could not find contract')
|
||||
return
|
||||
}
|
||||
|
||||
const commentSnapshot = await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
.collection('comments')
|
||||
.doc(txn.data.commentId)
|
||||
.get()
|
||||
if (!commentSnapshot.exists) {
|
||||
log('Could not find comment')
|
||||
return
|
||||
}
|
||||
const comment = commentSnapshot.data() as Comment
|
||||
|
||||
await createNotification(
|
||||
txn.id,
|
||||
'tip',
|
||||
'created',
|
||||
sender,
|
||||
eventId,
|
||||
txn.amount.toString(),
|
||||
contract,
|
||||
'comment',
|
||||
receiver.id,
|
||||
txn.data?.commentId,
|
||||
comment.text
|
||||
)
|
||||
}
|
|
@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore
|
|||
// ignore the update we just made
|
||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
|
||||
|
||||
await firestore
|
||||
.collection('groups')
|
||||
|
|
|
@ -1,96 +1,46 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { partition, sumBy } from 'lodash'
|
||||
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { User } from '../../common/user'
|
||||
|
||||
export const redeemShares = async (userId: string, contractId: string) => {
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
return await firestore.runTransaction(async (trans) => {
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { mechanism, outcomeType } = contract
|
||||
if (
|
||||
!(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') ||
|
||||
mechanism !== 'cpmm-1'
|
||||
)
|
||||
return { status: 'success' }
|
||||
const { mechanism } = contract
|
||||
if (mechanism !== 'cpmm-1') return { status: 'success' }
|
||||
|
||||
const betsSnap = await transaction.get(
|
||||
firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('userId', '==', userId)
|
||||
)
|
||||
const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
|
||||
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
|
||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
|
||||
const amount = Math.min(yesShares, noShares)
|
||||
if (amount <= 0) return
|
||||
|
||||
const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPaid = Math.min(prevLoanAmount, amount)
|
||||
const netAmount = amount - loanPaid
|
||||
|
||||
const p = getProbability(contract)
|
||||
const createdTime = Date.now()
|
||||
|
||||
const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
|
||||
const yesBet: Bet = {
|
||||
id: yesDoc.id,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount: p * -amount,
|
||||
shares: -amount,
|
||||
loanAmount: loanPaid ? -loanPaid / 2 : 0,
|
||||
outcome: 'YES',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
|
||||
const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
|
||||
const noBet: Bet = {
|
||||
id: noDoc.id,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount: (1 - p) * -amount,
|
||||
shares: -amount,
|
||||
loanAmount: loanPaid ? -loanPaid / 2 : 0,
|
||||
outcome: 'NO',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
|
||||
if (netAmount === 0) {
|
||||
return { status: 'success' }
|
||||
}
|
||||
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
|
||||
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
const userSnap = await trans.get(userDoc)
|
||||
if (!userSnap.exists) return { status: 'error', message: 'User not found' }
|
||||
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const newBalance = user.balance + netAmount
|
||||
|
||||
if (!isFinite(newBalance)) {
|
||||
throw new Error('Invalid user balance for ' + user.username)
|
||||
}
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
transaction.create(yesDoc, yesBet)
|
||||
transaction.create(noDoc, noBet)
|
||||
const yesDoc = betsColl.doc()
|
||||
const noDoc = betsColl.doc()
|
||||
trans.update(userDoc, { balance: newBalance })
|
||||
trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
|
||||
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
|
||||
|
||||
return { status: 'success' }
|
||||
})
|
||||
|
|
|
@ -46,7 +46,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
|
||||
if (shares > maxShares + 0.000000000001)
|
||||
if (shares > maxShares)
|
||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
|
||||
|
|
|
@ -1,138 +1,138 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { subtractObjects } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { noFees } from '../../common/fees'
|
||||
|
||||
import { APIError } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as CPMMContract
|
||||
|
||||
const liquidityCollection = firestore.collection(
|
||||
`contracts/${contractId}/liquidity`
|
||||
)
|
||||
|
||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const userShares = getUserLiquidityShares(
|
||||
userId,
|
||||
contract,
|
||||
liquidities
|
||||
)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) =>
|
||||
!liquidities[i].isAnte && liquidities[i].userId === userId
|
||||
)
|
||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||
|
||||
const payout = Math.min(...Object.values(userShares))
|
||||
if (payout <= 0) return {}
|
||||
|
||||
const newBalance = lp.balance + payout
|
||||
const newTotalDeposits = lp.totalDeposits + payout
|
||||
trans.update(lpDoc, {
|
||||
balance: newBalance,
|
||||
totalDeposits: newTotalDeposits,
|
||||
} as Partial<User>)
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
|
||||
const minPoolShares = Math.min(...Object.values(newPool))
|
||||
const adjustedTotal = contract.totalLiquidity - payout
|
||||
|
||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
|
||||
const prob = getProbability(contract)
|
||||
|
||||
// surplus shares become user's bets
|
||||
const bets = Object.entries(userShares)
|
||||
.map(([outcome, shares]) =>
|
||||
shares - payout < 1 // don't create bet if less than 1 share
|
||||
? undefined
|
||||
: ({
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount:
|
||||
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
||||
shares: shares - payout,
|
||||
outcome,
|
||||
probBefore: prob,
|
||||
probAfter: prob,
|
||||
createdTime: Date.now(),
|
||||
isLiquidityProvision: true,
|
||||
fees: noFees,
|
||||
} as Omit<Bet, 'id'>)
|
||||
)
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
for (const bet of bets) {
|
||||
const doc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
trans.create(doc, { id: doc.id, ...bet })
|
||||
}
|
||||
|
||||
return userShares
|
||||
})
|
||||
.then(async (result) => {
|
||||
// redeem surplus bet with pre-existing bets
|
||||
await redeemShares(userId, contractId)
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
})
|
||||
.catch((e) => {
|
||||
return { status: 'error', message: e.message }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
||||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { subtractObjects } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { noFees } from '../../common/fees'
|
||||
|
||||
import { APIError } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as CPMMContract
|
||||
|
||||
const liquidityCollection = firestore.collection(
|
||||
`contracts/${contractId}/liquidity`
|
||||
)
|
||||
|
||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const userShares = getUserLiquidityShares(
|
||||
userId,
|
||||
contract,
|
||||
liquidities
|
||||
)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) =>
|
||||
!liquidities[i].isAnte && liquidities[i].userId === userId
|
||||
)
|
||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||
|
||||
const payout = Math.min(...Object.values(userShares))
|
||||
if (payout <= 0) return {}
|
||||
|
||||
const newBalance = lp.balance + payout
|
||||
const newTotalDeposits = lp.totalDeposits + payout
|
||||
trans.update(lpDoc, {
|
||||
balance: newBalance,
|
||||
totalDeposits: newTotalDeposits,
|
||||
} as Partial<User>)
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
|
||||
const minPoolShares = Math.min(...Object.values(newPool))
|
||||
const adjustedTotal = contract.totalLiquidity - payout
|
||||
|
||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
|
||||
const prob = getProbability(contract)
|
||||
|
||||
// surplus shares become user's bets
|
||||
const bets = Object.entries(userShares)
|
||||
.map(([outcome, shares]) =>
|
||||
shares - payout < 1 // don't create bet if less than 1 share
|
||||
? undefined
|
||||
: ({
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount:
|
||||
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
||||
shares: shares - payout,
|
||||
outcome,
|
||||
probBefore: prob,
|
||||
probAfter: prob,
|
||||
createdTime: Date.now(),
|
||||
isLiquidityProvision: true,
|
||||
fees: noFees,
|
||||
} as Omit<Bet, 'id'>)
|
||||
)
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
for (const bet of bets) {
|
||||
const doc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
trans.create(doc, { id: doc.id, ...bet })
|
||||
}
|
||||
|
||||
return userShares
|
||||
})
|
||||
.then(async (result) => {
|
||||
// redeem surplus bet with pre-existing bets
|
||||
await redeemShares(userId, contractId)
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
})
|
||||
.catch((e) => {
|
||||
return { status: 'error', message: e.message }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -19,6 +19,7 @@ module.exports = {
|
|||
],
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@next/next/no-typos': 'off',
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
env: {
|
||||
|
|
|
@ -53,7 +53,7 @@ export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
|
||||
className={`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
|
||||
>
|
||||
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
||||
</div>
|
||||
|
|
|
@ -7,11 +7,7 @@ import { Bet } from 'common/bet'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import {
|
||||
contractPath,
|
||||
contractPool,
|
||||
getBinaryProbPercent,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { contractPath, contractPool } from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
|
@ -21,6 +17,7 @@ import { Title } from '../title'
|
|||
import { TweetButton } from '../tweet-button'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { TagsInput } from 'web/components/tags-input'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -68,9 +65,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<Row className="justify-start gap-4">
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract, false)}
|
||||
tweetText={getTweetText(contract)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
<div />
|
||||
|
||||
|
@ -155,23 +153,13 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract, isCreator: boolean) => {
|
||||
const { question, creatorName, resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const getTweetText = (contract: Contract) => {
|
||||
const { question, resolution } = contract
|
||||
|
||||
const tweetQuestion = isCreator
|
||||
? question
|
||||
: `${question}\nAsked by ${creatorName}.`
|
||||
const tweetDescription = resolution
|
||||
? `Resolved ${resolution}!`
|
||||
: isBinary
|
||||
? `Currently ${getBinaryProbPercent(
|
||||
contract
|
||||
)} chance, place your bets here:`
|
||||
: `Submit your own answer:`
|
||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||
|
||||
const timeParam = `${Date.now()}`.substring(7)
|
||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
||||
|
||||
return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
|
||||
return `${question}\n\n${url}${tweetDescription}`
|
||||
}
|
||||
|
|
|
@ -79,6 +79,11 @@ export const ContractOverview = (props: {
|
|||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
outcomeType === 'FREE_RESPONSE' &&
|
||||
resolution && (
|
||||
|
|
58
web/components/copy-contract-button.tsx
Normal file
58
web/components/copy-contract-button.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Contract } from 'common/contract'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function DuplicateContractButton(props: {
|
||||
contract: Contract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
|
||||
return (
|
||||
<a
|
||||
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #a78bfa',
|
||||
// violet-400
|
||||
color: '#a78bfa',
|
||||
}}
|
||||
href={duplicateContractHref(contract)}
|
||||
onClick={trackCallback('duplicate market')}
|
||||
target="_blank"
|
||||
>
|
||||
<DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
<div>Duplicate</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// Pass along the Uri to create a new contract
|
||||
function duplicateContractHref(contract: Contract) {
|
||||
const params = {
|
||||
q: contract.question,
|
||||
closeTime: contract.closeTime || 0,
|
||||
description:
|
||||
(contract.description ? `${contract.description}\n\n` : '') +
|
||||
`(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
|
||||
outcomeType: contract.outcomeType,
|
||||
} as Record<string, any>
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
params.min = contract.min
|
||||
params.max = contract.max
|
||||
params.isLogScale = contract.isLogScale
|
||||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||
}
|
||||
|
||||
return (
|
||||
`/create?` +
|
||||
Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
)
|
||||
}
|
|
@ -224,7 +224,7 @@ export function FeedComment(props: {
|
|||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
'flex space-x-1.5 transition-all duration-1000 sm:space-x-3',
|
||||
'flex space-x-1.5 sm:space-x-3',
|
||||
highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : ''
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserIcon } from '@heroicons/react/outline'
|
||||
import { UserIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { User } from 'common/user'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
|
@ -6,13 +6,24 @@ import clsx from 'clsx'
|
|||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
|
||||
export function FilterSelectUsers(props: {
|
||||
setSelectedUsers: (users: User[]) => void
|
||||
selectedUsers: User[]
|
||||
ignoreUserIds: string[]
|
||||
showSelectedUsersTitle?: boolean
|
||||
selectedUsersClassName?: string
|
||||
maxUsers?: number
|
||||
}) {
|
||||
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
|
||||
const {
|
||||
ignoreUserIds,
|
||||
selectedUsers,
|
||||
setSelectedUsers,
|
||||
showSelectedUsersTitle,
|
||||
selectedUsersClassName,
|
||||
maxUsers,
|
||||
} = props
|
||||
const users = useUsers()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
|
||||
|
@ -24,94 +35,124 @@ export function FilterSelectUsers(props: {
|
|||
return (
|
||||
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
||||
!ignoreUserIds.includes(user.id) &&
|
||||
user.name.toLowerCase().includes(query.toLowerCase())
|
||||
(user.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(query.toLowerCase()))
|
||||
)
|
||||
})
|
||||
)
|
||||
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
|
||||
|
||||
const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mt-1 rounded-md">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="user name"
|
||||
id="user name"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
||||
placeholder="Austin Chen"
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
as="div"
|
||||
className={clsx(
|
||||
'relative inline-block w-full overflow-y-scroll text-right',
|
||||
beginQuerying && 'h-36'
|
||||
)}
|
||||
>
|
||||
{({}) => (
|
||||
<Transition
|
||||
show={beginQuerying}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
{shouldShow && (
|
||||
<>
|
||||
<div className="relative mt-1 rounded-md">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="user name"
|
||||
id="user name"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
||||
placeholder="Austin Chen"
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
as="div"
|
||||
className={clsx(
|
||||
'relative inline-block w-full overflow-y-scroll text-right',
|
||||
beginQuerying && 'h-36'
|
||||
)}
|
||||
>
|
||||
<Menu.Items
|
||||
static={true}
|
||||
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{filteredUsers.map((user: User) => (
|
||||
<Menu.Item key={user.id}>
|
||||
{({ active }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700',
|
||||
'group flex items-center px-4 py-2 text-sm'
|
||||
{({}) => (
|
||||
<Transition
|
||||
show={beginQuerying}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static={true}
|
||||
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{filteredUsers.map((user: User) => (
|
||||
<Menu.Item key={user.id}>
|
||||
{({ active }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700',
|
||||
'group flex items-center px-4 py-2 text-sm'
|
||||
)}
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'xs'}
|
||||
className={'mr-2'}
|
||||
/>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'xs'}
|
||||
className={'mr-2'}
|
||||
/>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
)}
|
||||
</Menu>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{selectedUsers.length > 0 && (
|
||||
<>
|
||||
<div className={'mb-2'}>Added members:</div>
|
||||
<Row className="mt-0 grid grid-cols-6 gap-2">
|
||||
<div className={'mb-2'}>
|
||||
{showSelectedUsersTitle && 'Added members:'}
|
||||
</div>
|
||||
<Row
|
||||
className={clsx(
|
||||
'mt-0 grid grid-cols-6 gap-2',
|
||||
selectedUsersClassName
|
||||
)}
|
||||
>
|
||||
{selectedUsers.map((user: User) => (
|
||||
<div key={user.id} className="col-span-2 flex items-center">
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'sm'}
|
||||
<div
|
||||
key={user.id}
|
||||
className="col-span-2 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Row className={'items-center'}>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'sm'}
|
||||
/>
|
||||
<UserLink
|
||||
username={user.username}
|
||||
className="ml-2"
|
||||
name={user.name}
|
||||
/>
|
||||
</Row>
|
||||
<XIcon
|
||||
onClick={() =>
|
||||
setSelectedUsers([
|
||||
...selectedUsers.filter((u) => u.id != user.id),
|
||||
])
|
||||
}
|
||||
className=" h-5 w-5 cursor-pointer text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-2">{user.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
|
|
|
@ -63,6 +63,7 @@ export function BottomNavBar() {
|
|||
currentPage={currentPage}
|
||||
item={{
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=bets`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
|
@ -94,6 +95,7 @@ export function BottomNavBar() {
|
|||
|
||||
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||
const { item, currentPage } = props
|
||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
|
||||
return (
|
||||
<Link href={item.href}>
|
||||
|
@ -102,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
|||
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||
currentPage === item.href && 'bg-gray-200 text-indigo-700'
|
||||
)}
|
||||
onClick={trackCallback('navbar: ' + item.name)}
|
||||
onClick={track}
|
||||
>
|
||||
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||
{item.name}
|
||||
|
|
|
@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo'
|
|||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React from 'react'
|
||||
import React, { useEffect } 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'
|
||||
|
@ -26,6 +26,8 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { usePreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
|
@ -120,6 +122,7 @@ function getMoreMobileNav() {
|
|||
|
||||
export type Item = {
|
||||
name: string
|
||||
trackingEventName?: string
|
||||
href: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
@ -217,7 +220,11 @@ export default function Sidebar(props: { className?: string }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
|
@ -236,14 +243,36 @@ export default function Sidebar(props: { className?: string }) {
|
|||
<div className="h-[1px] bg-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||
const { currentPage, memberItems } = props
|
||||
function GroupsList(props: {
|
||||
currentPage: string
|
||||
memberItems: Item[]
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { currentPage, memberItems, user } = props
|
||||
const preferredNotifications = usePreferredNotifications(user?.id, {
|
||||
unseenOnly: true,
|
||||
customHref: '/group/',
|
||||
})
|
||||
|
||||
// Set notification as seen if our current page is equal to the isSeenOnHref property
|
||||
useEffect(() => {
|
||||
preferredNotifications.forEach((notification) => {
|
||||
if (notification.isSeenOnHref === currentPage) {
|
||||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [currentPage, preferredNotifications])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
|
@ -256,9 +285,14 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
|||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="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"
|
||||
className={clsx(
|
||||
'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',
|
||||
preferredNotifications.some(
|
||||
(n) => !n.isSeen && n.isSeenOnHref === item.href
|
||||
) && 'font-bold'
|
||||
)}
|
||||
>
|
||||
<span className="truncate"> {item.name}</span>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline'
|
|||
import clsx from 'clsx'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
import { requestBonuses } from 'web/lib/firebase/api-call'
|
||||
|
||||
export default function NotificationsIcon(props: { className?: string }) {
|
||||
const user = useUser()
|
||||
const notifications = usePreferredGroupedNotifications(user?.id, {
|
||||
const privateUser = usePrivateUser(user?.id)
|
||||
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
|
||||
unseenOnly: true,
|
||||
})
|
||||
const [seen, setSeen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!privateUser) return
|
||||
|
||||
if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000)
|
||||
requestBonuses({}).catch((error) => {
|
||||
console.log("couldn't get bonuses:", error.message)
|
||||
})
|
||||
}, [privateUser])
|
||||
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
||||
|
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
|||
<div className={'relative'}>
|
||||
{!seen && notifications && notifications.length > 0 && (
|
||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
||||
{notifications.length}
|
||||
{notifications.length > NOTIFICATIONS_PER_PAGE
|
||||
? `${NOTIFICATIONS_PER_PAGE}+`
|
||||
: notifications.length}
|
||||
</div>
|
||||
)}
|
||||
<BellIcon className={clsx(props.className)} />
|
||||
|
|
|
@ -10,9 +10,11 @@ import { Row } from 'web/components/layout/row'
|
|||
import { Avatar } from 'web/components/avatar'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
import { useReferrals } from 'web/hooks/use-referrals'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { getUser, updateUser } from 'web/lib/firebase/users'
|
||||
|
||||
export function ReferralsButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const referralIds = useReferrals(user.id)
|
||||
|
||||
|
@ -28,6 +30,7 @@ export function ReferralsButton(props: { user: User }) {
|
|||
referralIds={referralIds ?? []}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
@ -38,8 +41,21 @@ function ReferralsDialog(props: {
|
|||
referralIds: string[]
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
currentUser?: User
|
||||
}) {
|
||||
const { user, referralIds, isOpen, setIsOpen } = props
|
||||
const { user, referralIds, isOpen, setIsOpen, currentUser } = props
|
||||
const [referredBy, setReferredBy] = useState<User[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
const [referredByUser, setReferredByUser] = useState<User | null>()
|
||||
useEffect(() => {
|
||||
if (isOpen && !referredByUser && user?.referredByUserId) {
|
||||
getUser(user.referredByUserId).then((user) => {
|
||||
setReferredByUser(user)
|
||||
})
|
||||
}
|
||||
}, [isOpen, referredByUser, user.referredByUserId])
|
||||
|
||||
useEffect(() => {
|
||||
prefetchUsers(referralIds)
|
||||
|
@ -56,6 +72,75 @@ function ReferralsDialog(props: {
|
|||
title: 'Referrals',
|
||||
content: <ReferralsList userIds={referralIds} />,
|
||||
},
|
||||
{
|
||||
title: 'Referred by',
|
||||
content: (
|
||||
<>
|
||||
{user.id === currentUser?.id && !referredByUser ? (
|
||||
<>
|
||||
<FilterSelectUsers
|
||||
setSelectedUsers={setReferredBy}
|
||||
selectedUsers={referredBy}
|
||||
ignoreUserIds={[currentUser.id]}
|
||||
showSelectedUsersTitle={false}
|
||||
selectedUsersClassName={'grid-cols-2 '}
|
||||
maxUsers={1}
|
||||
/>
|
||||
<Row className={'mt-0 justify-end'}>
|
||||
<button
|
||||
className={
|
||||
referredBy.length === 0
|
||||
? 'hidden'
|
||||
: 'btn btn-primary btn-md my-2 w-24 normal-case'
|
||||
}
|
||||
disabled={referredBy.length === 0 || isSubmitting}
|
||||
onClick={() => {
|
||||
setIsSubmitting(true)
|
||||
updateUser(currentUser.id, {
|
||||
referredByUserId: referredBy[0].id,
|
||||
})
|
||||
.then(async () => {
|
||||
setErrorText('')
|
||||
setIsSubmitting(false)
|
||||
setReferredBy([])
|
||||
setIsOpen(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsSubmitting(false)
|
||||
setErrorText(error.message)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Row>
|
||||
<span className={'text-warning'}>
|
||||
{referredBy.length > 0 &&
|
||||
'Careful: you can only set who referred you once!'}
|
||||
</span>
|
||||
<span className={'text-error'}>{errorText}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="justify-center text-gray-700">
|
||||
{referredByUser ? (
|
||||
<Row className={'items-center gap-2 p-2'}>
|
||||
<Avatar
|
||||
username={referredByUser.username}
|
||||
avatarUrl={referredByUser.avatarUrl}
|
||||
/>
|
||||
<UserLink
|
||||
username={referredByUser.username}
|
||||
name={referredByUser.name}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<span className={'text-gray-500'}>No one...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
@ -45,15 +45,16 @@ export function UserLink(props: {
|
|||
username: string
|
||||
showUsername?: boolean
|
||||
className?: string
|
||||
justFirstName?: boolean
|
||||
}) {
|
||||
const { name, username, showUsername, className } = props
|
||||
const { name, username, showUsername, className, justFirstName } = props
|
||||
|
||||
return (
|
||||
<SiteLink
|
||||
href={`/${username}`}
|
||||
className={clsx('z-10 truncate', className)}
|
||||
>
|
||||
{name}
|
||||
{justFirstName ? name.split(' ')[0] : name}
|
||||
{showUsername && ` (@${username})`}
|
||||
</SiteLink>
|
||||
)
|
||||
|
@ -159,7 +160,7 @@ export function UserPage(props: {
|
|||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={20}
|
||||
size={24}
|
||||
className="bg-white ring-4 ring-white"
|
||||
/>
|
||||
</div>
|
||||
|
@ -202,7 +203,7 @@ export function UserPage(props: {
|
|||
<Row className="gap-4">
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
<ReferralsButton user={user} />
|
||||
<ReferralsButton user={user} currentUser={currentUser} />
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
|
||||
|
|
|
@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
|
|||
|
||||
export type NotificationGroup = {
|
||||
notifications: Notification[]
|
||||
sourceContractId: string
|
||||
groupedById: string
|
||||
isSeen: boolean
|
||||
timePeriod: string
|
||||
type: 'income' | 'normal'
|
||||
}
|
||||
|
||||
export function usePreferredGroupedNotifications(
|
||||
|
@ -37,25 +38,45 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
new Date(notification.createdTime).toDateString()
|
||||
)
|
||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||
// Group notifications by contract:
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType === 'bonus' || notification.sourceType === 'tip'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
|
||||
)
|
||||
if (incomeNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
notifications: incomeNotifications,
|
||||
groupedById: 'income' + day,
|
||||
isSeen: incomeNotifications[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'income',
|
||||
})
|
||||
}
|
||||
// Group notifications by contract, filtering out bonuses:
|
||||
const groupedNotificationsByContractId = groupBy(
|
||||
notificationGroupsByDay[day],
|
||||
normalNotificationsGroupedByDay,
|
||||
(notification) => {
|
||||
return notification.sourceContractId
|
||||
}
|
||||
)
|
||||
notificationGroups = notificationGroups.concat(
|
||||
map(groupedNotificationsByContractId, (notifications, contractId) => {
|
||||
const notificationsForContractId = groupedNotificationsByContractId[
|
||||
contractId
|
||||
].sort((a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
})
|
||||
// Create a notification group for each contract within each day
|
||||
const notificationGroup: NotificationGroup = {
|
||||
notifications: groupedNotificationsByContractId[contractId].sort(
|
||||
(a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
}
|
||||
),
|
||||
sourceContractId: contractId,
|
||||
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
|
||||
notifications: notificationsForContractId,
|
||||
groupedById: contractId,
|
||||
isSeen: notificationsForContractId[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'normal',
|
||||
}
|
||||
return notificationGroup
|
||||
})
|
||||
|
@ -64,11 +85,11 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
return notificationGroups
|
||||
}
|
||||
|
||||
function usePreferredNotifications(
|
||||
export function usePreferredNotifications(
|
||||
userId: string | undefined,
|
||||
options: { unseenOnly: boolean }
|
||||
options: { unseenOnly: boolean; customHref?: string }
|
||||
) {
|
||||
const { unseenOnly } = options
|
||||
const { unseenOnly, customHref } = options
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
||||
|
@ -93,9 +114,11 @@ function usePreferredNotifications(
|
|||
const notificationsToShow = getAppropriateNotifications(
|
||||
notifications,
|
||||
privateUser.notificationPreferences
|
||||
).filter((n) =>
|
||||
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
|
||||
)
|
||||
setUserAppropriateNotifications(notificationsToShow)
|
||||
}, [privateUser, notifications])
|
||||
}, [privateUser, notifications, customHref])
|
||||
|
||||
return userAppropriateNotifications
|
||||
}
|
||||
|
|
|
@ -77,3 +77,7 @@ export function sellBet(params: any) {
|
|||
export function createGroup(params: any) {
|
||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||
}
|
||||
|
||||
export function requestBonuses(params: any) {
|
||||
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
|
||||
}
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Page } from 'web/components/page'
|
||||
import { UserPage } from 'web/components/user-page'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
function SignInCard() {
|
||||
return (
|
||||
<div className="card glass sm:card-side text-neutral-content mx-4 my-12 max-w-sm bg-green-600 shadow-xl transition-all hover:bg-green-600 hover:shadow-xl sm:mx-auto">
|
||||
<div className="p-4">
|
||||
<img
|
||||
src="/logo-bg-white.png"
|
||||
className="h-20 w-20 rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="card-body max-w-md">
|
||||
<h2 className="card-title font-major-mono">Welcome!</h2>
|
||||
<p>Sign in to get started</p>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn glass rounded-full hover:bg-green-500"
|
||||
onClick={firebaseLogin}
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const user = useUser()
|
||||
return user ? (
|
||||
<UserPage user={user} currentUser={user} />
|
||||
) : (
|
||||
<Page>
|
||||
<SignInCard />
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -62,13 +62,19 @@ function UsersTable() {
|
|||
class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
|
||||
href="/${cell}">@${cell}</a>`),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
name: 'Name',
|
||||
formatter: (cell) =>
|
||||
html(`<span class="whitespace-nowrap">${cell}</span>`),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email',
|
||||
},
|
||||
{
|
||||
id: 'createdTime',
|
||||
name: 'Created Time',
|
||||
name: 'Created',
|
||||
formatter: (cell) =>
|
||||
html(
|
||||
`<span class="whitespace-nowrap">${dayjs(cell as number).format(
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
||||
import { createMarket } from 'web/lib/firebase/api-call'
|
||||
import { FIXED_ANTE, MINIMUM_ANTE } from 'common/antes'
|
||||
import { FIXED_ANTE } from 'common/antes'
|
||||
import { InfoTooltip } from 'web/components/info-tooltip'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
@ -25,17 +25,34 @@ import { useTracking } from 'web/hooks/use-tracking'
|
|||
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import { CATEGORIES } from 'common/categories'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
// get query params:
|
||||
const router = useRouter()
|
||||
const { groupId } = router.query as { groupId: string }
|
||||
useTracking('view create page')
|
||||
const creator = useUser()
|
||||
type NewQuestionParams = {
|
||||
groupId?: string
|
||||
q: string
|
||||
type: string
|
||||
description: string
|
||||
closeTime: string
|
||||
outcomeType: string
|
||||
// Params for PSEUDO_NUMERIC outcomeType
|
||||
min?: string
|
||||
max?: string
|
||||
isLogScale?: string
|
||||
initValue?: string
|
||||
}
|
||||
|
||||
export default function Create() {
|
||||
useTracking('view create page')
|
||||
const router = useRouter()
|
||||
const params = router.query as NewQuestionParams
|
||||
// TODO: Not sure why Question is pulled out as its own component;
|
||||
// Maybe merge into newContract and then we don't need useEffect here.
|
||||
const [question, setQuestion] = useState('')
|
||||
useEffect(() => {
|
||||
setQuestion(params.q ?? '')
|
||||
}, [params.q])
|
||||
|
||||
const creator = useUser()
|
||||
useEffect(() => {
|
||||
if (creator === null) router.push('/')
|
||||
}, [creator, router])
|
||||
|
@ -65,11 +82,7 @@ export default function Create() {
|
|||
</div>
|
||||
</form>
|
||||
<Spacer h={6} />
|
||||
<NewContract
|
||||
question={question}
|
||||
groupId={groupId}
|
||||
creator={creator}
|
||||
/>
|
||||
<NewContract question={question} params={params} creator={creator} />
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
@ -80,20 +93,21 @@ export default function Create() {
|
|||
export function NewContract(props: {
|
||||
creator: User
|
||||
question: string
|
||||
groupId?: string
|
||||
params?: NewQuestionParams
|
||||
}) {
|
||||
const { creator, question, groupId } = props
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||
const { creator, question, params } = props
|
||||
const { groupId, initValue } = params ?? {}
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>(
|
||||
(params?.outcomeType as outcomeType) ?? 'BINARY'
|
||||
)
|
||||
const [initialProb] = useState(50)
|
||||
|
||||
const [minString, setMinString] = useState('')
|
||||
const [maxString, setMaxString] = useState('')
|
||||
const [isLogScale, setIsLogScale] = useState(false)
|
||||
const [initialValueString, setInitialValueString] = useState('')
|
||||
const [minString, setMinString] = useState(params?.min ?? '')
|
||||
const [maxString, setMaxString] = useState(params?.max ?? '')
|
||||
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
|
||||
const [initialValueString, setInitialValueString] = useState(initValue)
|
||||
|
||||
const [description, setDescription] = useState('')
|
||||
// const [tagText, setTagText] = useState<string>(tag ?? '')
|
||||
// const tags = parseWordsAsTags(tagText)
|
||||
const [description, setDescription] = useState(params?.description ?? '')
|
||||
useEffect(() => {
|
||||
if (groupId && creator)
|
||||
getGroup(groupId).then((group) => {
|
||||
|
@ -105,25 +119,23 @@ export function NewContract(props: {
|
|||
}, [creator, groupId])
|
||||
const [ante, _setAnte] = useState(FIXED_ANTE)
|
||||
|
||||
// useEffect(() => {
|
||||
// if (ante === null && creator) {
|
||||
// const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100
|
||||
// setAnte(initialAnte)
|
||||
// }
|
||||
// }, [ante, creator])
|
||||
|
||||
// const [anteError, setAnteError] = useState<string | undefined>()
|
||||
// If params.closeTime is set, extract out the specified date and time
|
||||
// By default, close the market a week from today
|
||||
const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD')
|
||||
const [closeDate, setCloseDate] = useState<undefined | string>(weekFromToday)
|
||||
const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59')
|
||||
const timeInMs = Number(params?.closeTime ?? 0)
|
||||
const initDate = timeInMs
|
||||
? dayjs(timeInMs).format('YYYY-MM-DD')
|
||||
: weekFromToday
|
||||
const initTime = timeInMs ? dayjs(timeInMs).format('HH:mm') : '23:59'
|
||||
const [closeDate, setCloseDate] = useState<undefined | string>(initDate)
|
||||
const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>(initTime)
|
||||
|
||||
const [marketInfoText, setMarketInfoText] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [showGroupSelector, setShowGroupSelector] = useState(true)
|
||||
const [category, setCategory] = useState<string>('')
|
||||
|
||||
const closeTime = closeDate
|
||||
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
||||
|
@ -156,7 +168,6 @@ export function NewContract(props: {
|
|||
question.length > 0 &&
|
||||
ante !== undefined &&
|
||||
ante !== null &&
|
||||
ante >= MINIMUM_ANTE &&
|
||||
ante <= balance &&
|
||||
// closeTime must be in the future
|
||||
closeTime &&
|
||||
|
@ -197,7 +208,6 @@ export function NewContract(props: {
|
|||
initialValue,
|
||||
isLogScale: (min ?? 0) < 0 ? false : isLogScale,
|
||||
groupId: selectedGroup?.id,
|
||||
tags: category ? [category] : undefined,
|
||||
})
|
||||
)
|
||||
track('create market', {
|
||||
|
@ -339,28 +349,6 @@ export function NewContract(props: {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="form-control max-w-[265px] items-start">
|
||||
<label className="label gap-2">
|
||||
<span className="mb-1">Category</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
className={clsx(
|
||||
'select select-bordered w-full text-sm',
|
||||
category === '' ? 'font-normal text-gray-500' : ''
|
||||
)}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.currentTarget.value ?? '')}
|
||||
>
|
||||
<option value={''}>None</option>
|
||||
{Object.entries(CATEGORIES).map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={'mt-2'}>
|
||||
<GroupSelector
|
||||
selectedGroup={selectedGroup}
|
||||
|
|
|
@ -46,7 +46,7 @@ export default function ClaimPage() {
|
|||
if (result.data.status == 'error') {
|
||||
throw new Error(result.data.message)
|
||||
}
|
||||
router.push('/account?claimed-mana=yes')
|
||||
user && router.push(`/${user.username}?claimed-mana=yes`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
const message =
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Notification,
|
||||
notification_reason_types,
|
||||
notification_source_types,
|
||||
notification_source_update_types,
|
||||
} from 'common/notification'
|
||||
import { Notification, notification_source_types } from 'common/notification'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
|
@ -31,47 +26,40 @@ import {
|
|||
ProbPercentLabel,
|
||||
} from 'web/components/outcome-label'
|
||||
import {
|
||||
groupNotifications,
|
||||
NotificationGroup,
|
||||
usePreferredGroupedNotifications,
|
||||
} from 'web/hooks/use-notifications'
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
|
||||
import { groupBy, sum, uniq } from 'lodash'
|
||||
|
||||
export const NOTIFICATIONS_PER_PAGE = 30
|
||||
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
||||
|
||||
export default function Notifications() {
|
||||
const user = useUser()
|
||||
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
|
||||
NotificationGroup[] | undefined
|
||||
>(undefined)
|
||||
const allNotificationGroups = usePreferredGroupedNotifications(user?.id, {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const groupedNotifications = usePreferredGroupedNotifications(user?.id, {
|
||||
unseenOnly: false,
|
||||
})
|
||||
|
||||
const [paginatedNotificationGroups, setPaginatedNotificationGroups] =
|
||||
useState<NotificationGroup[]>([])
|
||||
useEffect(() => {
|
||||
if (!allNotificationGroups) return
|
||||
// Don't re-add notifications that are visible right now or have been seen already.
|
||||
const currentlyVisibleUnseenNotificationIds = Object.values(
|
||||
unseenNotificationGroups ?? []
|
||||
)
|
||||
.map((n) => n.notifications.map((n) => n.id))
|
||||
.flat()
|
||||
const unseenGroupedNotifications = groupNotifications(
|
||||
allNotificationGroups
|
||||
.map((notification: NotificationGroup) => notification.notifications)
|
||||
.flat()
|
||||
.filter(
|
||||
(notification: Notification) =>
|
||||
!notification.isSeen ||
|
||||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
|
||||
)
|
||||
)
|
||||
setUnseenNotificationGroups(unseenGroupedNotifications)
|
||||
|
||||
// We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allNotificationGroups])
|
||||
if (!groupedNotifications) return
|
||||
const start = (page - 1) * NOTIFICATIONS_PER_PAGE
|
||||
const end = start + NOTIFICATIONS_PER_PAGE
|
||||
const maxNotificationsToShow = groupedNotifications.slice(start, end)
|
||||
const remainingNotification = groupedNotifications.slice(end)
|
||||
for (const notification of remainingNotification) {
|
||||
if (notification.isSeen) break
|
||||
else setNotificationsAsSeen(notification.notifications)
|
||||
}
|
||||
setPaginatedNotificationGroups(maxNotificationsToShow)
|
||||
}, [groupedNotifications, page])
|
||||
|
||||
if (user === undefined) {
|
||||
return <LoadingIndicator />
|
||||
|
@ -80,7 +68,6 @@ export default function Notifications() {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
// TODO: use infinite scroll
|
||||
return (
|
||||
<Page>
|
||||
<div className={'p-2 sm:p-4'}>
|
||||
|
@ -90,13 +77,18 @@ export default function Notifications() {
|
|||
defaultIndex={0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'New Notifications',
|
||||
content: unseenNotificationGroups ? (
|
||||
title: 'Notifications',
|
||||
content: groupedNotifications ? (
|
||||
<div className={''}>
|
||||
{unseenNotificationGroups.length === 0 &&
|
||||
"You don't have any new notifications."}
|
||||
{unseenNotificationGroups.map((notification) =>
|
||||
notification.notifications.length === 1 ? (
|
||||
{paginatedNotificationGroups.length === 0 &&
|
||||
"You don't have any notifications. Try changing your settings to see more."}
|
||||
{paginatedNotificationGroups.map((notification) =>
|
||||
notification.type === 'income' ? (
|
||||
<IncomeNotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={notification.groupedById + notification.timePeriod}
|
||||
/>
|
||||
) : notification.notifications.length === 1 ? (
|
||||
<NotificationItem
|
||||
notification={notification.notifications[0]}
|
||||
key={notification.notifications[0].id}
|
||||
|
@ -104,39 +96,55 @@ export default function Notifications() {
|
|||
) : (
|
||||
<NotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={
|
||||
notification.sourceContractId +
|
||||
notification.timePeriod
|
||||
}
|
||||
key={notification.groupedById + notification.timePeriod}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'All Notifications',
|
||||
content: allNotificationGroups ? (
|
||||
<div className={''}>
|
||||
{allNotificationGroups.length === 0 &&
|
||||
"You don't have any notifications. Try changing your settings to see more."}
|
||||
{allNotificationGroups.map((notification) =>
|
||||
notification.notifications.length === 1 ? (
|
||||
<NotificationItem
|
||||
notification={notification.notifications[0]}
|
||||
key={notification.notifications[0].id}
|
||||
/>
|
||||
) : (
|
||||
<NotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={
|
||||
notification.sourceContractId +
|
||||
notification.timePeriod
|
||||
}
|
||||
/>
|
||||
)
|
||||
{groupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
|
||||
<nav
|
||||
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
{page === 1
|
||||
? page
|
||||
: (page - 1) * NOTIFICATIONS_PER_PAGE}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-medium">
|
||||
{page * NOTIFICATIONS_PER_PAGE}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">
|
||||
{groupedNotifications.length}
|
||||
</span>{' '}
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 justify-between sm:justify-end">
|
||||
<a
|
||||
href="#"
|
||||
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() =>
|
||||
page <
|
||||
groupedNotifications?.length /
|
||||
NOTIFICATIONS_PER_PAGE && setPage(page + 1)
|
||||
}
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -158,13 +166,12 @@ export default function Notifications() {
|
|||
)
|
||||
}
|
||||
|
||||
const setNotificationsAsSeen = (notifications: Notification[]) => {
|
||||
export const setNotificationsAsSeen = (notifications: Notification[]) => {
|
||||
notifications.forEach((notification) => {
|
||||
if (!notification.isSeen)
|
||||
updateDoc(
|
||||
doc(db, `users/${notification.userId}/notifications/`, notification.id),
|
||||
{
|
||||
...notification,
|
||||
isSeen: true,
|
||||
viewTime: new Date(),
|
||||
}
|
||||
|
@ -173,31 +180,302 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
|
|||
return notifications
|
||||
}
|
||||
|
||||
function NotificationGroupItem(props: {
|
||||
function IncomeNotificationGroupItem(props: {
|
||||
notificationGroup: NotificationGroup
|
||||
className?: string
|
||||
}) {
|
||||
const { notificationGroup, className } = props
|
||||
const { notifications } = notificationGroup
|
||||
const {
|
||||
sourceContractTitle,
|
||||
sourceContractSlug,
|
||||
sourceContractCreatorUsername,
|
||||
} = notifications[0]
|
||||
const numSummaryLines = 3
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(
|
||||
notifications.some((n) => !n.isSeen)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen(notifications)
|
||||
}, [notifications])
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) setHighlighted(false)
|
||||
}, [expanded])
|
||||
|
||||
const totalIncome = sum(
|
||||
notifications.map((notification) =>
|
||||
notification.sourceText ? parseInt(notification.sourceText) : 0
|
||||
)
|
||||
)
|
||||
// Loop through the contracts and combine the notification items into one
|
||||
function combineNotificationsByAddingNumericSourceTexts(
|
||||
notifications: Notification[]
|
||||
) {
|
||||
const newNotifications = []
|
||||
const groupedNotificationsBySourceType = groupBy(
|
||||
notifications,
|
||||
(n) => n.sourceType
|
||||
)
|
||||
for (const sourceType in groupedNotificationsBySourceType) {
|
||||
const groupedNotificationsByContractId = groupBy(
|
||||
groupedNotificationsBySourceType[sourceType],
|
||||
(notification) => {
|
||||
return notification.sourceContractId
|
||||
}
|
||||
)
|
||||
for (const contractId in groupedNotificationsByContractId) {
|
||||
const notificationsForContractId =
|
||||
groupedNotificationsByContractId[contractId]
|
||||
if (notificationsForContractId.length === 1) {
|
||||
newNotifications.push(notificationsForContractId[0])
|
||||
continue
|
||||
}
|
||||
let sum = 0
|
||||
notificationsForContractId.forEach(
|
||||
(notification) =>
|
||||
notification.sourceText &&
|
||||
(sum = parseInt(notification.sourceText) + sum)
|
||||
)
|
||||
const uniqueUsers = uniq(
|
||||
notificationsForContractId.map((notification) => {
|
||||
return notification.sourceUserUsername
|
||||
})
|
||||
)
|
||||
|
||||
const newNotification = {
|
||||
...notificationsForContractId[0],
|
||||
sourceText: sum.toString(),
|
||||
sourceUserUsername:
|
||||
uniqueUsers.length > 1
|
||||
? MULTIPLE_USERS_KEY
|
||||
: notificationsForContractId[0].sourceType,
|
||||
}
|
||||
newNotifications.push(newNotification)
|
||||
}
|
||||
}
|
||||
return newNotifications
|
||||
}
|
||||
|
||||
const combinedNotifs =
|
||||
combineNotificationsByAddingNumericSourceTexts(notifications)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
|
||||
className,
|
||||
!expanded ? 'hover:bg-gray-100' : ''
|
||||
!expanded ? 'hover:bg-gray-100' : '',
|
||||
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded && (
|
||||
<span
|
||||
className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<TrendingUpIcon className={'text-primary h-7 w-7'} />
|
||||
<div className={'flex truncate'}>
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
||||
>
|
||||
<span>
|
||||
{'Daily Income Summary: '}
|
||||
<span className={'text-primary'}>
|
||||
{'+' + formatMoney(totalIncome)}
|
||||
</span>
|
||||
</span>
|
||||
<RelativeTimestamp time={notifications[0].createdTime} />
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<div>
|
||||
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
|
||||
{' '}
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-1 ml-1 gap-1 whitespace-pre-line',
|
||||
!expanded ? 'line-clamp-4' : ''
|
||||
)}
|
||||
>
|
||||
{!expanded ? (
|
||||
<>
|
||||
{combinedNotifs
|
||||
.slice(0, numSummaryLines)
|
||||
.map((notification) => (
|
||||
<IncomeNotificationItem
|
||||
notification={notification}
|
||||
justSummary={true}
|
||||
key={notification.id}
|
||||
/>
|
||||
))}
|
||||
<div className={'text-sm text-gray-500 hover:underline '}>
|
||||
{combinedNotifs.length - numSummaryLines > 0
|
||||
? 'And ' +
|
||||
(combinedNotifs.length - numSummaryLines) +
|
||||
' more...'
|
||||
: ''}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{combinedNotifs.map((notification) => (
|
||||
<IncomeNotificationItem
|
||||
notification={notification}
|
||||
key={notification.id}
|
||||
justSummary={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'mt-6 border-b border-gray-300'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IncomeNotificationItem(props: {
|
||||
notification: Notification
|
||||
justSummary?: boolean
|
||||
}) {
|
||||
const { notification, justSummary } = props
|
||||
const {
|
||||
sourceType,
|
||||
sourceUserName,
|
||||
reason,
|
||||
sourceUserUsername,
|
||||
createdTime,
|
||||
} = notification
|
||||
const [highlighted] = useState(!notification.isSeen)
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
||||
function getReasonForShowingIncomeNotification(simple: boolean) {
|
||||
const { sourceText } = notification
|
||||
let reasonText = ''
|
||||
if (sourceType === 'bonus' && sourceText) {
|
||||
reasonText = !simple
|
||||
? `bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} unique bettors`
|
||||
: ' bonus for unique bettors on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you` : `in tips on`
|
||||
}
|
||||
return <span className={'flex-shrink-0'}>{reasonText}</span>
|
||||
}
|
||||
|
||||
if (justSummary) {
|
||||
return (
|
||||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
||||
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
|
||||
<div className={'flex pl-1 sm:pl-0'}>
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
<div className={'mr-1 text-black'}>
|
||||
<NotificationTextLabel
|
||||
contract={null}
|
||||
defaultText={notification.sourceText ?? ''}
|
||||
className={'line-clamp-1'}
|
||||
notification={notification}
|
||||
justSummary={true}
|
||||
/>
|
||||
</div>
|
||||
<span className={'flex truncate'}>
|
||||
{getReasonForShowingIncomeNotification(true)}
|
||||
<NotificationLink notification={notification} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white px-2 pt-6 text-sm sm:px-4',
|
||||
highlighted && 'bg-indigo-200 hover:bg-indigo-100'
|
||||
)}
|
||||
>
|
||||
<a href={getSourceUrl(notification)}>
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<div className={'flex max-w-xl shrink '}>
|
||||
{sourceType && reason && (
|
||||
<div className={'inline'}>
|
||||
<span className={'mr-1'}>
|
||||
<NotificationTextLabel
|
||||
contract={null}
|
||||
defaultText={notification.sourceText ?? ''}
|
||||
notification={notification}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{sourceType != 'bonus' &&
|
||||
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
||||
<span className={'mr-1 truncate'}>Multiple users</span>
|
||||
) : (
|
||||
<UserLink
|
||||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-1 flex-shrink-0'}
|
||||
justFirstName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{getReasonForShowingIncomeNotification(false)}
|
||||
<span className={'ml-1 flex hidden sm:inline-block'}>
|
||||
on
|
||||
<NotificationLink notification={notification} />
|
||||
</span>
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
</Row>
|
||||
<span className={'flex truncate text-gray-500 sm:hidden'}>
|
||||
on
|
||||
<NotificationLink notification={notification} />
|
||||
</span>
|
||||
<div className={'mt-4 border-b border-gray-300'} />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationGroupItem(props: {
|
||||
notificationGroup: NotificationGroup
|
||||
className?: string
|
||||
}) {
|
||||
const { notificationGroup, className } = props
|
||||
const { notifications } = notificationGroup
|
||||
const { sourceContractTitle } = notifications[0]
|
||||
const numSummaryLines = 3
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(
|
||||
notifications.some((n) => !n.isSeen)
|
||||
)
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen(notifications)
|
||||
}, [notifications])
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) setHighlighted(false)
|
||||
}, [expanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
|
||||
className,
|
||||
!expanded ? 'hover:bg-gray-100' : '',
|
||||
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
|
@ -209,27 +487,18 @@ function NotificationGroupItem(props: {
|
|||
)}
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<EmptyAvatar multi />
|
||||
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
|
||||
<div className={'flex truncate pl-2'}>
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
||||
className={' flex cursor-pointer truncate pl-1 sm:pl-0'}
|
||||
>
|
||||
{sourceContractTitle ? (
|
||||
<span>
|
||||
{'Activity on '}
|
||||
<a
|
||||
href={
|
||||
sourceContractCreatorUsername
|
||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||
: ''
|
||||
}
|
||||
className={
|
||||
'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||
}
|
||||
>
|
||||
{sourceContractTitle}
|
||||
</a>
|
||||
</span>
|
||||
<>
|
||||
<span className={'flex-shrink-0'}>{'Activity on '}</span>
|
||||
<span className={'truncate'}>
|
||||
<NotificationLink notification={notifications[0]} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Other activity'
|
||||
)}
|
||||
|
@ -240,7 +509,13 @@ function NotificationGroupItem(props: {
|
|||
<div>
|
||||
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
|
||||
{' '}
|
||||
<div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}>
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-1 ml-1 gap-1 whitespace-pre-line',
|
||||
!expanded ? 'line-clamp-4' : ''
|
||||
)}
|
||||
>
|
||||
{' '}
|
||||
{!expanded ? (
|
||||
<>
|
||||
{notifications.slice(0, numSummaryLines).map((notification) => {
|
||||
|
@ -267,6 +542,7 @@ function NotificationGroupItem(props: {
|
|||
notification={notification}
|
||||
key={notification.id}
|
||||
justSummary={false}
|
||||
hideTitle={true}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
@ -432,7 +708,7 @@ function NotificationSettings() {
|
|||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Referral bonuses you've received"}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've ever bet or commented on"}
|
||||
|
@ -476,22 +752,82 @@ function NotificationSettings() {
|
|||
)
|
||||
}
|
||||
|
||||
function isNotificationAboutContractResolution(
|
||||
sourceType: notification_source_types | undefined,
|
||||
sourceUpdateType: notification_source_update_types | undefined,
|
||||
contract: Contract | null | undefined
|
||||
) {
|
||||
function NotificationLink(props: { notification: Notification }) {
|
||||
const { notification } = props
|
||||
const {
|
||||
sourceType,
|
||||
sourceContractTitle,
|
||||
sourceContractCreatorUsername,
|
||||
sourceContractSlug,
|
||||
sourceSlug,
|
||||
sourceTitle,
|
||||
} = notification
|
||||
return (
|
||||
(sourceType === 'contract' && sourceUpdateType === 'resolved') ||
|
||||
(sourceType === 'contract' && !sourceUpdateType && contract?.resolution)
|
||||
<a
|
||||
href={
|
||||
sourceContractCreatorUsername
|
||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||
: sourceType === 'group' && sourceSlug
|
||||
? `${groupPath(sourceSlug)}`
|
||||
: ''
|
||||
}
|
||||
className={
|
||||
'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm'
|
||||
}
|
||||
>
|
||||
{sourceContractTitle || sourceTitle}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function getSourceUrl(notification: Notification) {
|
||||
const {
|
||||
sourceType,
|
||||
sourceId,
|
||||
sourceUserUsername,
|
||||
sourceContractCreatorUsername,
|
||||
sourceContractSlug,
|
||||
sourceSlug,
|
||||
} = notification
|
||||
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
||||
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||
if (
|
||||
sourceContractCreatorUsername &&
|
||||
sourceContractSlug &&
|
||||
sourceType === 'user'
|
||||
)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||
if (sourceType === 'tip')
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
sourceId ?? '',
|
||||
sourceType
|
||||
)}`
|
||||
}
|
||||
|
||||
function getSourceIdForLinkComponent(
|
||||
sourceId: string,
|
||||
sourceType?: notification_source_types
|
||||
) {
|
||||
switch (sourceType) {
|
||||
case 'answer':
|
||||
return `answer-${sourceId}`
|
||||
case 'comment':
|
||||
return sourceId
|
||||
case 'contract':
|
||||
return ''
|
||||
default:
|
||||
return sourceId
|
||||
}
|
||||
}
|
||||
|
||||
function NotificationItem(props: {
|
||||
notification: Notification
|
||||
justSummary?: boolean
|
||||
hideTitle?: boolean
|
||||
}) {
|
||||
const { notification, justSummary } = props
|
||||
const { notification, justSummary, hideTitle } = props
|
||||
const {
|
||||
sourceType,
|
||||
sourceId,
|
||||
|
@ -503,11 +839,8 @@ function NotificationItem(props: {
|
|||
sourceUserUsername,
|
||||
createdTime,
|
||||
sourceText,
|
||||
sourceContractTitle,
|
||||
sourceContractCreatorUsername,
|
||||
sourceContractSlug,
|
||||
sourceSlug,
|
||||
sourceTitle,
|
||||
} = notification
|
||||
|
||||
const [defaultNotificationText, setDefaultNotificationText] =
|
||||
|
@ -522,38 +855,12 @@ function NotificationItem(props: {
|
|||
}
|
||||
}, [reasonText, sourceText])
|
||||
|
||||
const [highlighted] = useState(!notification.isSeen)
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
||||
function getSourceUrl() {
|
||||
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
||||
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||
if (
|
||||
sourceContractCreatorUsername &&
|
||||
sourceContractSlug &&
|
||||
sourceType === 'user'
|
||||
)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
sourceId ?? ''
|
||||
)}`
|
||||
}
|
||||
|
||||
function getSourceIdForLinkComponent(sourceId: string) {
|
||||
switch (sourceType) {
|
||||
case 'answer':
|
||||
return `answer-${sourceId}`
|
||||
case 'comment':
|
||||
return sourceId
|
||||
case 'contract':
|
||||
return ''
|
||||
default:
|
||||
return sourceId
|
||||
}
|
||||
}
|
||||
|
||||
if (justSummary) {
|
||||
return (
|
||||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
||||
|
@ -563,18 +870,13 @@ function NotificationItem(props: {
|
|||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-0 flex-shrink-0'}
|
||||
justFirstName={true}
|
||||
/>
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
<span className={'flex-shrink-0'}>
|
||||
{sourceType &&
|
||||
reason &&
|
||||
getReasonForShowingNotification(
|
||||
sourceType,
|
||||
reason,
|
||||
sourceUpdateType,
|
||||
undefined,
|
||||
true
|
||||
).replace(' on', '')}
|
||||
getReasonForShowingNotification(notification, true, true)}
|
||||
</span>
|
||||
<div className={'ml-1 text-black'}>
|
||||
<NotificationTextLabel
|
||||
|
@ -593,8 +895,13 @@ function NotificationItem(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
|
||||
<a href={getSourceUrl()}>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white px-2 pt-6 text-sm sm:px-4',
|
||||
highlighted && 'bg-indigo-200 hover:bg-indigo-100'
|
||||
)}
|
||||
>
|
||||
<a href={getSourceUrl(notification)}>
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<Avatar
|
||||
avatarUrl={sourceUserAvatarUrl}
|
||||
|
@ -608,51 +915,38 @@ function NotificationItem(props: {
|
|||
'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0'
|
||||
}
|
||||
>
|
||||
<UserLink
|
||||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-0 flex-shrink-0'}
|
||||
/>
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
{sourceType && reason && (
|
||||
<div className={'inline truncate'}>
|
||||
{getReasonForShowingNotification(
|
||||
sourceType,
|
||||
reason,
|
||||
sourceUpdateType,
|
||||
undefined,
|
||||
false,
|
||||
sourceSlug
|
||||
)}
|
||||
<a
|
||||
href={
|
||||
sourceContractCreatorUsername
|
||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||
: sourceType === 'group' && sourceSlug
|
||||
? `${groupPath(sourceSlug)}`
|
||||
: ''
|
||||
}
|
||||
className={
|
||||
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||
}
|
||||
>
|
||||
{sourceContractTitle || sourceTitle}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sourceUpdateType != 'closed' && (
|
||||
<UserLink
|
||||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-0 flex-shrink-0'}
|
||||
justFirstName={true}
|
||||
/>
|
||||
)}
|
||||
{sourceType && reason && (
|
||||
<div className={'inline flex truncate'}>
|
||||
<span className={'ml-1 flex-shrink-0'}>
|
||||
{getReasonForShowingNotification(notification, false, true)}
|
||||
</span>
|
||||
{!hideTitle && (
|
||||
<NotificationLink notification={notification} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sourceId &&
|
||||
sourceContractSlug &&
|
||||
sourceContractCreatorUsername ? (
|
||||
<CopyLinkDateTimeComponent
|
||||
prefix={sourceContractCreatorUsername}
|
||||
slug={sourceContractSlug}
|
||||
createdTime={createdTime}
|
||||
elementId={getSourceIdForLinkComponent(sourceId)}
|
||||
className={'-mx-1 inline-flex sm:inline-block'}
|
||||
/>
|
||||
) : (
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
)}
|
||||
</div>
|
||||
{sourceId && sourceContractSlug && sourceContractCreatorUsername ? (
|
||||
<CopyLinkDateTimeComponent
|
||||
prefix={sourceContractCreatorUsername}
|
||||
slug={sourceContractSlug}
|
||||
createdTime={createdTime}
|
||||
elementId={getSourceIdForLinkComponent(sourceId)}
|
||||
className={'-mx-1 inline-flex sm:inline-block'}
|
||||
/>
|
||||
) : (
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<div className={'mt-1 ml-1 md:text-base'}>
|
||||
|
@ -684,13 +978,7 @@ function NotificationTextLabel(props: {
|
|||
return <span>{contract?.question || sourceContractTitle}</span>
|
||||
if (!sourceText) return <div />
|
||||
// Resolved contracts
|
||||
if (
|
||||
isNotificationAboutContractResolution(
|
||||
sourceType,
|
||||
sourceUpdateType,
|
||||
contract
|
||||
)
|
||||
) {
|
||||
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
|
||||
{
|
||||
if (sourceText === 'YES' || sourceText == 'NO') {
|
||||
return <BinaryOutcomeLabel outcome={sourceText as any} />
|
||||
|
@ -730,6 +1018,12 @@ function NotificationTextLabel(props: {
|
|||
return (
|
||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||
)
|
||||
} else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
|
||||
return (
|
||||
<span className="text-primary">
|
||||
{'+' + formatMoney(parseInt(sourceText))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// return default text
|
||||
return (
|
||||
|
@ -740,22 +1034,20 @@ function NotificationTextLabel(props: {
|
|||
}
|
||||
|
||||
function getReasonForShowingNotification(
|
||||
source: notification_source_types,
|
||||
reason: notification_reason_types,
|
||||
sourceUpdateType: notification_source_update_types | undefined,
|
||||
contract: Contract | undefined | null,
|
||||
notification: Notification,
|
||||
simple?: boolean,
|
||||
sourceSlug?: string
|
||||
replaceOn?: boolean
|
||||
) {
|
||||
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
||||
let reasonText: string
|
||||
switch (source) {
|
||||
switch (sourceType) {
|
||||
case 'comment':
|
||||
if (reason === 'reply_to_users_answer')
|
||||
reasonText = !simple ? 'replied to your answer on' : 'replied'
|
||||
reasonText = !simple ? 'replied to you on' : 'replied'
|
||||
else if (reason === 'tagged_user')
|
||||
reasonText = !simple ? 'tagged you in a comment on' : 'tagged you'
|
||||
reasonText = !simple ? 'tagged you on' : 'tagged you'
|
||||
else if (reason === 'reply_to_users_comment')
|
||||
reasonText = !simple ? 'replied to your comment on' : 'replied'
|
||||
reasonText = !simple ? 'replied to you on' : 'replied'
|
||||
else if (reason === 'on_users_contract')
|
||||
reasonText = !simple ? `commented on your question` : 'commented'
|
||||
else if (reason === 'on_contract_with_users_comment')
|
||||
|
@ -763,21 +1055,14 @@ function getReasonForShowingNotification(
|
|||
else if (reason === 'on_contract_with_users_answer')
|
||||
reasonText = `commented on`
|
||||
else if (reason === 'on_contract_with_users_shares_in')
|
||||
reasonText = `commented`
|
||||
reasonText = `commented on`
|
||||
else reasonText = `commented on`
|
||||
break
|
||||
case 'contract':
|
||||
if (reason === 'you_follow_user') reasonText = 'created a new question'
|
||||
else if (
|
||||
isNotificationAboutContractResolution(
|
||||
source,
|
||||
sourceUpdateType,
|
||||
contract
|
||||
)
|
||||
)
|
||||
reasonText = `resolved`
|
||||
if (reason === 'you_follow_user') reasonText = 'asked'
|
||||
else if (sourceUpdateType === 'resolved') reasonText = `resolved`
|
||||
else if (sourceUpdateType === 'closed')
|
||||
reasonText = `please resolve your question`
|
||||
reasonText = `Please resolve your question`
|
||||
else reasonText = `updated`
|
||||
break
|
||||
case 'answer':
|
||||
|
@ -808,5 +1093,10 @@ function getReasonForShowingNotification(
|
|||
default:
|
||||
reasonText = ''
|
||||
}
|
||||
return reasonText
|
||||
|
||||
return (
|
||||
<span className={'flex-shrink-0'}>
|
||||
{replaceOn ? reasonText.replace(' on', '') : reasonText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
208
yarn.lock
208
yarn.lock
|
@ -2181,6 +2181,20 @@
|
|||
google-gax "^2.24.1"
|
||||
protobufjs "^6.8.6"
|
||||
|
||||
"@google-cloud/functions-framework@3.1.2":
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-3.1.2.tgz#2cd92ce4307bf7f32555d028dca22e398473b410"
|
||||
integrity sha512-pYvEH65/Rqh1JNPdcBmorcV7Xoom2/iOSmbtYza8msro7Inl+qOYxbyMiQfySD2gwAyn38WyWPRqsDRcf/BFLg==
|
||||
dependencies:
|
||||
"@types/express" "4.17.13"
|
||||
body-parser "^1.18.3"
|
||||
cloudevents "^6.0.0"
|
||||
express "^4.16.4"
|
||||
minimist "^1.2.5"
|
||||
on-finished "^2.3.0"
|
||||
read-pkg-up "^7.0.1"
|
||||
semver "^7.3.5"
|
||||
|
||||
"@google-cloud/paginator@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b"
|
||||
|
@ -2926,7 +2940,7 @@
|
|||
"@types/qs" "*"
|
||||
"@types/range-parser" "*"
|
||||
|
||||
"@types/express@*", "@types/express@^4.17.13":
|
||||
"@types/express@*", "@types/express@4.17.13", "@types/express@^4.17.13":
|
||||
version "4.17.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
|
||||
integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
|
||||
|
@ -3049,6 +3063,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947"
|
||||
integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
|
||||
integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
|
||||
|
||||
"@types/parse-json@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||
|
@ -3498,7 +3517,7 @@ ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5:
|
|||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^8.0.0, ajv@^8.8.0:
|
||||
ajv@^8.0.0, ajv@^8.11.0, ajv@^8.8.0:
|
||||
version "8.11.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f"
|
||||
integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==
|
||||
|
@ -3750,6 +3769,11 @@ autoprefixer@^10.3.7, autoprefixer@^10.4.2:
|
|||
picocolors "^1.0.0"
|
||||
postcss-value-parser "^4.2.0"
|
||||
|
||||
available-typed-arrays@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
|
||||
integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
|
||||
|
||||
axe-core@^4.3.5:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c"
|
||||
|
@ -3880,7 +3904,7 @@ bluebird@^3.7.1:
|
|||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
body-parser@1.20.0:
|
||||
body-parser@1.20.0, body-parser@^1.18.3:
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
|
||||
integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==
|
||||
|
@ -4236,6 +4260,16 @@ clone-response@^1.0.2:
|
|||
dependencies:
|
||||
mimic-response "^1.0.0"
|
||||
|
||||
cloudevents@^6.0.0:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/cloudevents/-/cloudevents-6.0.2.tgz#7b4990a92c6c30f6790eb4b59207b4d8949fca12"
|
||||
integrity sha512-mn/4EZnAbhfb/TghubK2jPnxYM15JRjf8LnWJtXidiVKi5ZCkd+p9jyBZbL57w7nRm6oFAzJhjxRLsXd/DNaBQ==
|
||||
dependencies:
|
||||
ajv "^8.11.0"
|
||||
ajv-formats "^2.1.1"
|
||||
util "^0.12.4"
|
||||
uuid "^8.3.2"
|
||||
|
||||
clsx@1.1.1, clsx@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
|
||||
|
@ -5277,7 +5311,7 @@ error-ex@^1.3.1:
|
|||
dependencies:
|
||||
is-arrayish "^0.2.1"
|
||||
|
||||
es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5:
|
||||
es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0:
|
||||
version "1.20.1"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814"
|
||||
integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA==
|
||||
|
@ -5657,7 +5691,7 @@ execa@^5.0.0:
|
|||
signal-exit "^3.0.3"
|
||||
strip-final-newline "^2.0.0"
|
||||
|
||||
express@^4.17.1, express@^4.17.3:
|
||||
express@^4.16.4, express@^4.17.1, express@^4.17.3:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf"
|
||||
integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==
|
||||
|
@ -5871,7 +5905,7 @@ find-up@^3.0.0:
|
|||
dependencies:
|
||||
locate-path "^3.0.0"
|
||||
|
||||
find-up@^4.0.0:
|
||||
find-up@^4.0.0, find-up@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
|
||||
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
|
||||
|
@ -5981,6 +6015,13 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.7:
|
|||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
|
||||
integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==
|
||||
|
||||
for-each@^0.3.3:
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
|
||||
integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==
|
||||
dependencies:
|
||||
is-callable "^1.1.3"
|
||||
|
||||
fork-ts-checker-webpack-plugin@^6.5.0:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340"
|
||||
|
@ -6585,6 +6626,11 @@ hoist-non-react-statics@^3.1.0:
|
|||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
hosted-git-info@^2.1.4:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
|
||||
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
|
||||
|
||||
hpack.js@^2.1.6:
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
|
||||
|
@ -6945,6 +6991,14 @@ is-alphanumerical@^1.0.0:
|
|||
is-alphabetical "^1.0.0"
|
||||
is-decimal "^1.0.0"
|
||||
|
||||
is-arguments@^1.0.4:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
|
||||
integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-arrayish@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
|
||||
|
@ -6977,7 +7031,7 @@ is-buffer@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
|
||||
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
|
||||
|
||||
is-callable@^1.1.4, is-callable@^1.2.4:
|
||||
is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
|
||||
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
||||
|
@ -6989,7 +7043,7 @@ is-ci@^2.0.0:
|
|||
dependencies:
|
||||
ci-info "^2.0.0"
|
||||
|
||||
is-core-module@^2.2.0, is-core-module@^2.8.1:
|
||||
is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
|
||||
integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
|
||||
|
@ -7028,6 +7082,13 @@ is-fullwidth-code-point@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||
|
||||
is-generator-function@^1.0.7:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
|
||||
integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
|
||||
dependencies:
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||
|
@ -7161,6 +7222,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3:
|
|||
dependencies:
|
||||
has-symbols "^1.0.2"
|
||||
|
||||
is-typed-array@^1.1.3, is-typed-array@^1.1.9:
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67"
|
||||
integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A==
|
||||
dependencies:
|
||||
available-typed-arrays "^1.0.5"
|
||||
call-bind "^1.0.2"
|
||||
es-abstract "^1.20.0"
|
||||
for-each "^0.3.3"
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-typedarray@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
|
||||
|
@ -8126,6 +8198,16 @@ nopt@1.0.10:
|
|||
dependencies:
|
||||
abbrev "1"
|
||||
|
||||
normalize-package-data@^2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
|
||||
integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
|
||||
dependencies:
|
||||
hosted-git-info "^2.1.4"
|
||||
resolve "^1.10.0"
|
||||
semver "2 || 3 || 4 || 5"
|
||||
validate-npm-package-license "^3.0.1"
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
|
@ -8252,7 +8334,7 @@ obuf@^1.0.0, obuf@^1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
|
||||
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
|
||||
|
||||
on-finished@2.4.1:
|
||||
on-finished@2.4.1, on-finished@^2.3.0:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
||||
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
|
||||
|
@ -9463,6 +9545,25 @@ react@17.0.2, react@^17.0.1:
|
|||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
read-pkg-up@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
|
||||
integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
|
||||
dependencies:
|
||||
find-up "^4.1.0"
|
||||
read-pkg "^5.2.0"
|
||||
type-fest "^0.8.1"
|
||||
|
||||
read-pkg@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
|
||||
integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
|
||||
dependencies:
|
||||
"@types/normalize-package-data" "^2.4.0"
|
||||
normalize-package-data "^2.5.0"
|
||||
parse-json "^5.0.0"
|
||||
type-fest "^0.6.0"
|
||||
|
||||
readable-stream@1.1.x:
|
||||
version "1.1.14"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
||||
|
@ -9767,6 +9868,15 @@ resolve@^1.1.6, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.3.
|
|||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.10.0:
|
||||
version "1.22.1"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
|
||||
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
|
||||
dependencies:
|
||||
is-core-module "^2.9.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^2.0.0-next.3:
|
||||
version "2.0.0-next.3"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
|
||||
|
@ -9848,7 +9958,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
|||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
|
||||
safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
@ -9959,16 +10069,16 @@ semver-diff@^3.1.1:
|
|||
dependencies:
|
||||
semver "^6.3.0"
|
||||
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
|
||||
semver@7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@^5.4.1, semver@^5.6.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
|
||||
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
|
@ -10223,6 +10333,32 @@ spawn-command@^0.0.2-1:
|
|||
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0"
|
||||
integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=
|
||||
|
||||
spdx-correct@^3.0.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
|
||||
integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
|
||||
dependencies:
|
||||
spdx-expression-parse "^3.0.0"
|
||||
spdx-license-ids "^3.0.0"
|
||||
|
||||
spdx-exceptions@^2.1.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
|
||||
integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
|
||||
|
||||
spdx-expression-parse@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
|
||||
integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
|
||||
dependencies:
|
||||
spdx-exceptions "^2.1.0"
|
||||
spdx-license-ids "^3.0.0"
|
||||
|
||||
spdx-license-ids@^3.0.0:
|
||||
version "3.0.11"
|
||||
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95"
|
||||
integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==
|
||||
|
||||
spdy-transport@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"
|
||||
|
@ -10706,6 +10842,16 @@ type-fest@^0.20.2:
|
|||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||
|
||||
type-fest@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
|
||||
integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
|
||||
|
||||
type-fest@^0.8.1:
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
|
||||
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
|
||||
|
||||
type-fest@^2.5.0:
|
||||
version "2.13.0"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.13.0.tgz#d1ecee38af29eb2e863b22299a3d68ef30d2abfb"
|
||||
|
@ -10974,6 +11120,18 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
|
||||
util@^0.12.4:
|
||||
version "0.12.4"
|
||||
resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253"
|
||||
integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==
|
||||
dependencies:
|
||||
inherits "^2.0.3"
|
||||
is-arguments "^1.0.4"
|
||||
is-generator-function "^1.0.7"
|
||||
is-typed-array "^1.1.3"
|
||||
safe-buffer "^5.1.2"
|
||||
which-typed-array "^1.1.2"
|
||||
|
||||
utila@~0.4:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
|
||||
|
@ -10999,6 +11157,14 @@ v8-compile-cache@^2.0.3:
|
|||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
|
||||
|
||||
validate-npm-package-license@^3.0.1:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
|
||||
integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
|
||||
dependencies:
|
||||
spdx-correct "^3.0.0"
|
||||
spdx-expression-parse "^3.0.0"
|
||||
|
||||
value-equal@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c"
|
||||
|
@ -11232,6 +11398,18 @@ which-boxed-primitive@^1.0.2:
|
|||
is-string "^1.0.5"
|
||||
is-symbol "^1.0.3"
|
||||
|
||||
which-typed-array@^1.1.2:
|
||||
version "1.1.8"
|
||||
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f"
|
||||
integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw==
|
||||
dependencies:
|
||||
available-typed-arrays "^1.0.5"
|
||||
call-bind "^1.0.2"
|
||||
es-abstract "^1.20.0"
|
||||
for-each "^0.3.3"
|
||||
has-tostringtag "^1.0.0"
|
||||
is-typed-array "^1.1.9"
|
||||
|
||||
which@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
|
||||
|
|
Loading…
Reference in New Issue
Block a user