Merge branch 'main' into salemcenter
This commit is contained in:
commit
5bfd7d80b0
|
@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb(
|
||||||
prob: number,
|
prob: number,
|
||||||
outcome: 'YES' | 'NO'
|
outcome: 'YES' | 'NO'
|
||||||
) {
|
) {
|
||||||
|
if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity
|
||||||
if (outcome === 'NO') prob = 1 - prob
|
if (outcome === 'NO') prob = 1 - prob
|
||||||
|
|
||||||
// First, find an upper bound that leads to a more extreme probability than prob.
|
// First, find an upper bound that leads to a more extreme probability than prob.
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { difference } from 'lodash'
|
import { difference } from 'lodash'
|
||||||
|
|
||||||
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
|
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
|
||||||
|
|
||||||
export const CATEGORIES = {
|
export const CATEGORIES = {
|
||||||
politics: 'Politics',
|
politics: 'Politics',
|
||||||
technology: 'Technology',
|
technology: 'Technology',
|
||||||
|
@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [
|
||||||
'manifold',
|
'manifold',
|
||||||
'personal',
|
'personal',
|
||||||
'covid',
|
'covid',
|
||||||
'culture',
|
|
||||||
'gaming',
|
'gaming',
|
||||||
'crypto',
|
'crypto',
|
||||||
'world',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
|
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
|
||||||
|
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
|
||||||
|
name: CATEGORIES[c as category],
|
||||||
|
}))
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Answer } from './answer'
|
import { Answer } from './answer'
|
||||||
import { Fees } from './fees'
|
import { Fees } from './fees'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { GroupLink } from 'common/group'
|
||||||
|
|
||||||
export type AnyMechanism = DPM | CPMM
|
export type AnyMechanism = DPM | CPMM
|
||||||
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
||||||
|
@ -46,8 +47,10 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
collectedFees: Fees
|
collectedFees: Fees
|
||||||
|
|
||||||
groupSlugs?: string[]
|
groupSlugs?: string[]
|
||||||
|
groupLinks?: GroupLink[]
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
|
popularityScore?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
|
|
@ -23,6 +23,7 @@ export type EnvConfig = {
|
||||||
// Currency controls
|
// Currency controls
|
||||||
fixedAnte?: number
|
fixedAnte?: number
|
||||||
startingBalance?: number
|
startingBalance?: number
|
||||||
|
referralBonus?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig = {
|
type FirebaseConfig = {
|
||||||
|
|
|
@ -11,8 +11,19 @@ export type Group = {
|
||||||
contractIds: string[]
|
contractIds: string[]
|
||||||
|
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
|
mostRecentChatActivityTime?: number
|
||||||
|
mostRecentContractAddedTime?: number
|
||||||
}
|
}
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
export const MAX_ABOUT_LENGTH = 140
|
export const MAX_ABOUT_LENGTH = 140
|
||||||
export const MAX_ID_LENGTH = 60
|
export const MAX_ID_LENGTH = 60
|
||||||
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
|
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
|
||||||
|
export const GROUP_CHAT_SLUG = 'chat'
|
||||||
|
|
||||||
|
export type GroupLink = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
groupId: string
|
||||||
|
createdTime: number
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { sortBy, sumBy } from 'lodash'
|
import { sortBy, sum, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
|
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
|
||||||
import {
|
import {
|
||||||
|
@ -142,6 +142,13 @@ export const computeFills = (
|
||||||
limitProb: number | undefined,
|
limitProb: number | undefined,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[]
|
||||||
) => {
|
) => {
|
||||||
|
if (isNaN(betAmount)) {
|
||||||
|
throw new Error('Invalid bet amount: ${betAmount}')
|
||||||
|
}
|
||||||
|
if (isNaN(limitProb ?? 0)) {
|
||||||
|
throw new Error('Invalid limitProb: ${limitProb}')
|
||||||
|
}
|
||||||
|
|
||||||
const sortedBets = sortBy(
|
const sortedBets = sortBy(
|
||||||
unfilledBets.filter((bet) => bet.outcome !== outcome),
|
unfilledBets.filter((bet) => bet.outcome !== outcome),
|
||||||
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
|
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
|
||||||
|
@ -239,6 +246,32 @@ export const getBinaryCpmmBetInfo = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getBinaryBetStats = (
|
||||||
|
outcome: 'YES' | 'NO',
|
||||||
|
betAmount: number,
|
||||||
|
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||||
|
limitProb: number,
|
||||||
|
unfilledBets: LimitBet[]
|
||||||
|
) => {
|
||||||
|
const { newBet } = getBinaryCpmmBetInfo(
|
||||||
|
outcome,
|
||||||
|
betAmount ?? 0,
|
||||||
|
contract,
|
||||||
|
limitProb,
|
||||||
|
unfilledBets as LimitBet[]
|
||||||
|
)
|
||||||
|
const remainingMatched =
|
||||||
|
((newBet.orderAmount ?? 0) - newBet.amount) /
|
||||||
|
(outcome === 'YES' ? limitProb : 1 - limitProb)
|
||||||
|
const currentPayout = newBet.shares + remainingMatched
|
||||||
|
|
||||||
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
|
|
||||||
|
const totalFees = sum(Object.values(newBet.fees))
|
||||||
|
|
||||||
|
return { currentPayout, currentReturn, totalFees, newBet }
|
||||||
|
}
|
||||||
|
|
||||||
export const getNewBinaryDpmBetInfo = (
|
export const getNewBinaryDpmBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
amount: number,
|
amount: number,
|
||||||
|
|
|
@ -63,3 +63,4 @@ export type notification_reason_types =
|
||||||
| 'on_group_you_are_member_of'
|
| 'on_group_you_are_member_of'
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
|
| 'user_joined_from_your_group_invite'
|
||||||
|
|
|
@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
|
||||||
|
|
||||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
|
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||||
|
|
|
@ -16,8 +16,8 @@ export const getMappedValue =
|
||||||
const { min, max, isLogScale } = contract
|
const { min, max, isLogScale } = contract
|
||||||
|
|
||||||
if (isLogScale) {
|
if (isLogScale) {
|
||||||
const logValue = p * Math.log10(max - min)
|
const logValue = p * Math.log10(max - min + 1)
|
||||||
return 10 ** logValue + min
|
return 10 ** logValue + min - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return p * (max - min) + min
|
return p * (max - min) + min
|
||||||
|
@ -37,8 +37,11 @@ export const getPseudoProbability = (
|
||||||
max: number,
|
max: number,
|
||||||
isLogScale = false
|
isLogScale = false
|
||||||
) => {
|
) => {
|
||||||
|
if (value < min) return 0
|
||||||
|
if (value > max) return 1
|
||||||
|
|
||||||
if (isLogScale) {
|
if (isLogScale) {
|
||||||
return Math.log10(value - min) / Math.log10(max - min)
|
return Math.log10(value - min + 1) / Math.log10(max - min + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (value - min) / (max - min)
|
return (value - min) / (max - min)
|
||||||
|
|
|
@ -38,12 +38,14 @@ export type User = {
|
||||||
|
|
||||||
referredByUserId?: string
|
referredByUserId?: string
|
||||||
referredByContractId?: string
|
referredByContractId?: string
|
||||||
|
referredByGroupId?: string
|
||||||
|
lastPingTime?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
// for sus users, i.e. multiple sign ups for same person
|
// for sus users, i.e. multiple sign ups for same person
|
||||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||||
export const REFERRAL_AMOUNT = 500
|
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
@ -57,7 +59,6 @@ export type PrivateUser = {
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
notificationPreferences?: notification_subscribe_types
|
notificationPreferences?: notification_subscribe_types
|
||||||
lastTimeCheckedBonuses?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||||
|
@ -69,3 +70,6 @@ export type PortfolioMetrics = {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||||
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
|
@ -33,18 +33,24 @@ export function formatPercent(zeroToOne: number) {
|
||||||
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
|
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showPrecision = (x: number, sigfigs: number) =>
|
||||||
|
// convert back to number for weird formatting reason
|
||||||
|
`${Number(x.toPrecision(sigfigs))}`
|
||||||
|
|
||||||
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
|
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
|
||||||
export function formatLargeNumber(num: number, sigfigs = 2): string {
|
export function formatLargeNumber(num: number, sigfigs = 2): string {
|
||||||
const absNum = Math.abs(num)
|
const absNum = Math.abs(num)
|
||||||
if (absNum < 1000) {
|
if (absNum < 1) return showPrecision(num, sigfigs)
|
||||||
return '' + Number(num.toPrecision(sigfigs))
|
|
||||||
}
|
if (absNum < 100) return showPrecision(num, 2)
|
||||||
|
if (absNum < 1000) return showPrecision(num, 3)
|
||||||
|
if (absNum < 10000) return showPrecision(num, 4)
|
||||||
|
|
||||||
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
||||||
const suffixIdx = Math.floor(Math.log10(absNum) / 3)
|
const i = Math.floor(Math.log10(absNum) / 3)
|
||||||
const suffixStr = suffix[suffixIdx]
|
|
||||||
const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs)
|
const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
|
||||||
return `${Number(numStr)}${suffixStr}`
|
return `${numStr}${suffix[i] ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCamelCase(words: string) {
|
export function toCamelCase(words: string) {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text'
|
||||||
// other tiptap extensions
|
// other tiptap extensions
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import Iframe from './tiptap-iframe'
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
|
@ -49,6 +50,16 @@ export function parseWordsAsTags(text: string) {
|
||||||
return parseTags(taggedText)
|
return parseTags(taggedText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: fuzzy matching
|
||||||
|
export const wordIn = (word: string, corpus: string) =>
|
||||||
|
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
|
||||||
|
|
||||||
|
const checkAgainstQuery = (query: string, corpus: string) =>
|
||||||
|
query.split(' ').every((word) => wordIn(word, corpus))
|
||||||
|
|
||||||
|
export const searchInAny = (query: string, ...fields: string[]) =>
|
||||||
|
fields.some((field) => checkAgainstQuery(query, field))
|
||||||
|
|
||||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||||
export const exhibitExts = [
|
export const exhibitExts = [
|
||||||
Blockquote,
|
Blockquote,
|
||||||
|
@ -70,6 +81,7 @@ export const exhibitExts = [
|
||||||
|
|
||||||
Image,
|
Image,
|
||||||
Link,
|
Link,
|
||||||
|
Iframe,
|
||||||
]
|
]
|
||||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
||||||
|
|
||||||
|
|
92
common/util/tiptap-iframe.ts
Normal file
92
common/util/tiptap-iframe.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts
|
||||||
|
|
||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface IframeOptions {
|
||||||
|
allowFullscreen: boolean
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframe: {
|
||||||
|
setIframe: (options: { src: string }) => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These classes style the outer wrapper and the inner iframe;
|
||||||
|
// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue
|
||||||
|
const wrapperClasses = 'relative h-auto w-full overflow-hidden'
|
||||||
|
const iframeClasses = 'absolute top-0 left-0 h-full w-full'
|
||||||
|
|
||||||
|
export default Node.create<IframeOptions>({
|
||||||
|
name: 'iframe',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
allowFullscreen: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'iframe-wrapper' + ' ' + wrapperClasses,
|
||||||
|
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
|
||||||
|
style: 'padding-bottom: 20rem;',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
frameborder: {
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
allowfullscreen: {
|
||||||
|
default: this.options.allowFullscreen,
|
||||||
|
parseHTML: () => this.options.allowFullscreen,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'iframe' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
[
|
||||||
|
'iframe',
|
||||||
|
{
|
||||||
|
...HTMLAttributes,
|
||||||
|
class: HTMLAttributes.class + ' ' + iframeClasses,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setIframe:
|
||||||
|
(options: { src: string }) =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
|
const { selection } = tr
|
||||||
|
const node = this.type.create(options)
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
|
@ -34,6 +34,18 @@ response was a 4xx or 5xx.)
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /v0/user/[username]`
|
||||||
|
|
||||||
|
Gets a user by their username. Remember that usernames may change.
|
||||||
|
|
||||||
|
Requires no authorization.
|
||||||
|
|
||||||
|
### `GET /v0/user/by-id/[id]`
|
||||||
|
|
||||||
|
Gets a user by their unique ID. Many other API endpoints return this as the `userId`.
|
||||||
|
|
||||||
|
Requires no authorization.
|
||||||
|
|
||||||
### `GET /v0/markets`
|
### `GET /v0/markets`
|
||||||
|
|
||||||
Lists all markets, ordered by creation date descending.
|
Lists all markets, ordered by creation date descending.
|
||||||
|
@ -627,6 +639,7 @@ Requires no authorization.
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
- 2022-07-15: Add user by username and user by ID APIs
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 2022-06-08: Add paging to markets endpoint
|
||||||
- 2022-06-05: Add new authorized write endpoints
|
- 2022-06-05: Add new authorized write endpoints
|
||||||
- 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition
|
- 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition
|
||||||
|
|
|
@ -6,7 +6,12 @@ service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
function isAdmin() {
|
function isAdmin() {
|
||||||
return request.auth.uid == 'tUosjZRN6GRv81uRksJ67EIF0853' // James
|
return request.auth.token.email in [
|
||||||
|
'akrolsmir@gmail.com',
|
||||||
|
'jahooma@gmail.com',
|
||||||
|
'taowell@gmail.com',
|
||||||
|
'manticmarkets@gmail.com'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
match /stats/stats {
|
match /stats/stats {
|
||||||
|
@ -17,10 +22,11 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']);
|
||||||
|
// User referral rules
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['referredByUserId'])
|
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
|
||||||
// only one referral allowed per user
|
// only one referral allowed per user
|
||||||
&& !("referredByUserId" in resource.data)
|
&& !("referredByUserId" in resource.data)
|
||||||
// user can't refer themselves
|
// user can't refer themselves
|
||||||
|
@ -68,9 +74,9 @@ service cloud.firestore {
|
||||||
match /contracts/{contractId} {
|
match /contracts/{contractId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
|
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['description', 'closeTime'])
|
.hasOnly(['description', 'closeTime', 'question'])
|
||||||
&& resource.data.creatorId == request.auth.uid;
|
&& resource.data.creatorId == request.auth.uid;
|
||||||
allow update: if isAdmin();
|
allow update: if isAdmin();
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
|
|
|
@ -27,6 +27,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
||||||
|
|
||||||
1. `$ brew install java`
|
1. `$ brew install java`
|
||||||
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
|
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
|
||||||
|
|
||||||
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
|
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
|
||||||
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
|
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
|
||||||
4. `$ mkdir firestore_export` to create a folder to store the exported database
|
4. `$ mkdir firestore_export` to create a folder to store the exported database
|
||||||
|
@ -53,7 +54,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
0. `$ firebase use prod` to switch to prod
|
0. After merging, you need to manually deploy to backend:
|
||||||
|
1. `git checkout main`
|
||||||
|
1. `git pull origin main`
|
||||||
|
1. `$ firebase use prod` to switch to prod
|
||||||
1. `$ firebase deploy --only functions` to push your changes live!
|
1. `$ firebase deploy --only functions` to push your changes live!
|
||||||
(Future TODO: auto-deploy functions on Git push)
|
(Future TODO: auto-deploy functions on Git push)
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,9 @@ export const claimmanalink = newEndpoint({}, async (req, auth) => {
|
||||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||||
throw new APIError(500, 'Invalid amount')
|
throw new APIError(500, 'Invalid amount')
|
||||||
|
|
||||||
|
if (auth.uid === fromId)
|
||||||
|
throw new APIError(400, `You can't claim your own manalink`)
|
||||||
|
|
||||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||||
const fromSnap = await transaction.get(fromDoc)
|
const fromSnap = await transaction.get(fromDoc)
|
||||||
if (!fromSnap.exists) {
|
if (!fromSnap.exists) {
|
||||||
|
|
|
@ -15,11 +15,11 @@ import { Answer } from '../../common/answer'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
|
[userId: string]: { reason: notification_reason_types }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createNotification = async (
|
||||||
|
@ -29,12 +29,22 @@ export const createNotification = async (
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
sourceContract?: Contract,
|
miscData?: {
|
||||||
relatedSourceType?: notification_source_types,
|
contract?: Contract
|
||||||
relatedUserId?: string,
|
relatedSourceType?: notification_source_types
|
||||||
sourceSlug?: string,
|
relatedUserId?: string
|
||||||
sourceTitle?: string
|
slug?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
|
const {
|
||||||
|
contract: sourceContract,
|
||||||
|
relatedSourceType,
|
||||||
|
relatedUserId,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
} = miscData ?? {}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
@ -70,9 +80,8 @@ export const createNotification = async (
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
sourceContractTitle: sourceContract?.question,
|
sourceContractTitle: sourceContract?.question,
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
sourceSlug: slug ? slug : sourceContract?.slug,
|
||||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
sourceTitle: title ? title : sourceContract?.question,
|
||||||
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
|
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
})
|
})
|
||||||
|
@ -254,20 +263,6 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyUserReceivedReferralBonus = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
relatedUserId: string
|
|
||||||
) => {
|
|
||||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
// If the referrer is the market creator, just tell them they joined to bet on their market
|
|
||||||
reason:
|
|
||||||
sourceContract?.creatorId === relatedUserId
|
|
||||||
? 'user_joined_to_bet_on_your_market'
|
|
||||||
: 'you_referred_user',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -277,17 +272,6 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 getUsersToNotify = async () => {
|
const getUsersToNotify = async () => {
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
@ -296,10 +280,6 @@ export const createNotification = async (
|
||||||
} else if (sourceType === 'group' && relatedUserId) {
|
} else if (sourceType === 'group' && relatedUserId) {
|
||||||
if (sourceUpdateType === 'created')
|
if (sourceUpdateType === 'created')
|
||||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
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.
|
// The following functions need sourceContract to be defined.
|
||||||
|
@ -417,3 +397,84 @@ export const createBetFillNotification = async (
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createGroupCommentNotification = async (
|
||||||
|
fromUser: User,
|
||||||
|
toUserId: string,
|
||||||
|
comment: Comment,
|
||||||
|
group: Group,
|
||||||
|
idempotencyKey: string
|
||||||
|
) => {
|
||||||
|
if (toUserId === fromUser.id) return
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUserId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUserId,
|
||||||
|
reason: 'on_group_you_are_member_of',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: comment.id,
|
||||||
|
sourceType: 'comment',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: fromUser.name,
|
||||||
|
sourceUserUsername: fromUser.username,
|
||||||
|
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||||
|
sourceText: comment.text,
|
||||||
|
sourceSlug,
|
||||||
|
sourceTitle: `${group.name}`,
|
||||||
|
isSeenOnHref: sourceSlug,
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createReferralNotification = async (
|
||||||
|
toUser: User,
|
||||||
|
referredUser: User,
|
||||||
|
idempotencyKey: string,
|
||||||
|
bonusAmount: string,
|
||||||
|
referredByContract?: Contract,
|
||||||
|
referredByGroup?: Group
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUser.id,
|
||||||
|
reason: referredByGroup
|
||||||
|
? 'user_joined_from_your_group_invite'
|
||||||
|
: referredByContract?.creatorId === toUser.id
|
||||||
|
? 'user_joined_to_bet_on_your_market'
|
||||||
|
: 'you_referred_user',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: referredUser.id,
|
||||||
|
sourceType: 'user',
|
||||||
|
sourceUpdateType: 'updated',
|
||||||
|
sourceContractId: referredByContract?.id,
|
||||||
|
sourceUserName: referredUser.name,
|
||||||
|
sourceUserUsername: referredUser.username,
|
||||||
|
sourceUserAvatarUrl: referredUser.avatarUrl,
|
||||||
|
sourceText: bonusAmount,
|
||||||
|
// Only pass the contract referral details if they weren't referred to a group
|
||||||
|
sourceContractCreatorUsername: !referredByGroup
|
||||||
|
? referredByContract?.creatorUsername
|
||||||
|
: undefined,
|
||||||
|
sourceContractTitle: !referredByGroup
|
||||||
|
? referredByContract?.question
|
||||||
|
: undefined,
|
||||||
|
sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined,
|
||||||
|
sourceSlug: referredByGroup
|
||||||
|
? groupPath(referredByGroup.slug)
|
||||||
|
: referredByContract?.slug,
|
||||||
|
sourceTitle: referredByGroup
|
||||||
|
? referredByGroup.name
|
||||||
|
: referredByContract?.question,
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
|
MANIFOLD_AVATAR_URL,
|
||||||
|
MANIFOLD_USERNAME,
|
||||||
PrivateUser,
|
PrivateUser,
|
||||||
STARTING_BALANCE,
|
STARTING_BALANCE,
|
||||||
SUS_STARTING_BALANCE,
|
SUS_STARTING_BALANCE,
|
||||||
|
@ -157,11 +159,11 @@ const addUserToDefaultGroups = async (user: User) => {
|
||||||
id: welcomeCommentDoc.id,
|
id: welcomeCommentDoc.id,
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
userId: manifoldAccount,
|
userId: manifoldAccount,
|
||||||
text: `Welcome, ${user.name} (@${user.username})!`,
|
text: `Welcome, @${user.username} aka ${user.name}!`,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
userName: 'Manifold Markets',
|
userName: 'Manifold Markets',
|
||||||
userUsername: 'ManifoldMarkets',
|
userUsername: MANIFOLD_USERNAME,
|
||||||
userAvatarUrl: 'https://manifold.markets/logo-bg-white.png',
|
userAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
29
functions/src/email-templates/500-mana.html
Normal file
29
functions/src/email-templates/500-mana.html
Normal file
File diff suppressed because one or more lines are too long
|
@ -302,7 +302,7 @@ export const sendNewCommentEmail = async (
|
||||||
)}`
|
)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject = `Comment from ${commentorName} on ${question}`
|
const subject = `Comment on ${question}`
|
||||||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||||
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
|
|
|
@ -22,6 +22,7 @@ export * from './on-update-user'
|
||||||
export * from './on-create-comment-on-group'
|
export * from './on-create-comment-on-group'
|
||||||
export * from './on-create-txn'
|
export * from './on-create-txn'
|
||||||
export * from './on-delete-group'
|
export * from './on-delete-group'
|
||||||
|
export * from './score-contracts'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
|
@ -64,7 +64,7 @@ async function sendMarketCloseEmails() {
|
||||||
user,
|
user,
|
||||||
'closed' + contract.id.slice(6, contract.id.length),
|
'closed' + contract.id.slice(6, contract.id.length),
|
||||||
contract.closeTime?.toString() ?? new Date().toString(),
|
contract.closeTime?.toString() ?? new Date().toString(),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId,
|
||||||
answer.text,
|
answer.text,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
|
||||||
if (!previousUniqueBettorIds) {
|
if (!previousUniqueBettorIds) {
|
||||||
const contractBets = (
|
const contractBets = (
|
||||||
await firestore
|
await firestore.collection(`contracts/${contractId}/bets`).get()
|
||||||
.collection(`contracts/${contractId}/bets`)
|
|
||||||
.where('userId', '!=', contract.creatorId)
|
|
||||||
.get()
|
|
||||||
).docs.map((doc) => doc.data() as Bet)
|
).docs.map((doc) => doc.data() as Bet)
|
||||||
|
|
||||||
if (contractBets.length === 0) {
|
if (contractBets.length === 0) {
|
||||||
|
@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNewUniqueBettor =
|
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
|
||||||
!previousUniqueBettorIds.includes(bettorId) &&
|
|
||||||
bettorId !== contract.creatorId
|
|
||||||
|
|
||||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
|
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
|
||||||
// Update contract unique bettors
|
// Update contract unique bettors
|
||||||
|
@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!isNewUniqueBettor) return
|
|
||||||
|
// No need to give a bonus for the creator's bet
|
||||||
|
if (!isNewUniqueBettor || bettorId == contract.creatorId) return
|
||||||
|
|
||||||
// Create combined txn for all new unique bettors
|
// Create combined txn for all new unique bettors
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
|
@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
fromUser,
|
fromUser,
|
||||||
eventId + '-bonus',
|
eventId + '-bonus',
|
||||||
result.txn.amount + '',
|
result.txn.amount + '',
|
||||||
|
{
|
||||||
contract,
|
contract,
|
||||||
undefined,
|
slug: contract.slug,
|
||||||
// No need to set the user id, we'll use the contract creator id
|
title: contract.question,
|
||||||
undefined,
|
}
|
||||||
contract.slug,
|
|
||||||
contract.question
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions
|
||||||
? 'answer'
|
? 'answer'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const relatedUser = comment.replyToCommentId
|
const relatedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
|
@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
comment.text,
|
comment.text,
|
||||||
contract,
|
{ contract, relatedSourceType, relatedUserId }
|
||||||
relatedSourceType,
|
|
||||||
relatedUser
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Comment } from '../../common/comment'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { createNotification } from './create-notification'
|
import { createGroupCommentNotification } from './create-notification'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onCreateCommentOnGroup = functions.firestore
|
export const onCreateCommentOnGroup = functions.firestore
|
||||||
|
@ -29,23 +29,17 @@ export const onCreateCommentOnGroup = functions.firestore
|
||||||
|
|
||||||
const group = groupSnapshot.data() as Group
|
const group = groupSnapshot.data() as Group
|
||||||
await firestore.collection('groups').doc(groupId).update({
|
await firestore.collection('groups').doc(groupId).update({
|
||||||
mostRecentActivityTime: comment.createdTime,
|
mostRecentChatActivityTime: comment.createdTime,
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
group.memberIds.map(async (memberId) => {
|
group.memberIds.map(async (memberId) => {
|
||||||
return await createNotification(
|
return await createGroupCommentNotification(
|
||||||
comment.id,
|
|
||||||
'comment',
|
|
||||||
'created',
|
|
||||||
creatorSnapshot.data() as User,
|
creatorSnapshot.data() as User,
|
||||||
eventId,
|
|
||||||
comment.text,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
memberId,
|
memberId,
|
||||||
`/group/${group.slug}`,
|
comment,
|
||||||
`${group.name}`
|
group,
|
||||||
|
eventId
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore
|
||||||
contractCreator,
|
contractCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(contract.description as JSONContent),
|
richTextToString(contract.description as JSONContent),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore
|
||||||
groupCreator,
|
groupCreator,
|
||||||
eventId,
|
eventId,
|
||||||
group.about,
|
group.about,
|
||||||
undefined,
|
{
|
||||||
undefined,
|
relatedUserId: memberId,
|
||||||
memberId,
|
slug: group.slug,
|
||||||
group.slug,
|
title: group.name,
|
||||||
group.name
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
liquidityProvider,
|
liquidityProvider,
|
||||||
eventId,
|
eventId,
|
||||||
liquidity.amount.toString(),
|
liquidity.amount.toString(),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onDeleteGroup = functions.firestore
|
export const onDeleteGroup = functions.firestore
|
||||||
|
@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.where('groupSlugs', 'array-contains', group.slug)
|
.where('groupSlugs', 'array-contains', group.slug)
|
||||||
.get()
|
.get()
|
||||||
|
console.log("contracts with group's slug:", contracts)
|
||||||
|
|
||||||
for (const doc of contracts.docs) {
|
for (const doc of contracts.docs) {
|
||||||
const contract = doc.data() as Contract
|
const contract = doc.data() as Contract
|
||||||
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
|
(link) => link.slug !== group.slug
|
||||||
|
)
|
||||||
|
|
||||||
// remove the group from the contract
|
// remove the group from the contract
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
.update({
|
.update({
|
||||||
groupSlugs: (contract.groupSlugs ?? []).filter(
|
groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug),
|
||||||
(groupSlug) => groupSlug !== group.slug
|
groupLinks: newGroupLinks ?? [],
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore
|
||||||
followingUser,
|
followingUser,
|
||||||
eventId,
|
eventId,
|
||||||
'',
|
'',
|
||||||
undefined,
|
{ relatedUserId: follow.userId }
|
||||||
undefined,
|
|
||||||
follow.userId
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
resolutionText,
|
resolutionText,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
|
@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
sourceText,
|
sourceText,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -12,7 +12,15 @@ export const onUpdateGroup = functions.firestore
|
||||||
// ignore the update we just made
|
// ignore the update we just made
|
||||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
|
|
||||||
|
if (prevGroup.contractIds.length < group.contractIds.length) {
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(group.id)
|
||||||
|
.update({ mostRecentContractAddedTime: Date.now() })
|
||||||
|
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
|
||||||
|
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
|
||||||
|
}
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.collection('groups')
|
||||||
|
|
|
@ -2,11 +2,12 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
||||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
import { createNotification } from './create-notification'
|
import { createReferralNotification } from './create-notification'
|
||||||
import { ReferralTxn } from '../../common/txn'
|
import { ReferralTxn } from '../../common/txn'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from 'common/bet'
|
||||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||||
|
import { Group } from 'common/group'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateUser = functions.firestore
|
export const onUpdateUser = functions.firestore
|
||||||
|
@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
}
|
}
|
||||||
console.log(`referredByContract: ${referredByContract}`)
|
console.log(`referredByContract: ${referredByContract}`)
|
||||||
|
|
||||||
|
let referredByGroup: Group | undefined = undefined
|
||||||
|
if (user.referredByGroupId) {
|
||||||
|
const referredByGroupDoc = firestore.doc(
|
||||||
|
`groups/${user.referredByGroupId}`
|
||||||
|
)
|
||||||
|
referredByGroup = await transaction
|
||||||
|
.get(referredByGroupDoc)
|
||||||
|
.then((snap) => snap.data() as Group)
|
||||||
|
}
|
||||||
|
console.log(`referredByGroup: ${referredByGroup}`)
|
||||||
|
|
||||||
const txns = (
|
const txns = (
|
||||||
await firestore
|
await firestore
|
||||||
.collection('txns')
|
.collection('txns')
|
||||||
|
@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
||||||
})
|
})
|
||||||
|
|
||||||
await createNotification(
|
await createReferralNotification(
|
||||||
user.id,
|
referredByUser,
|
||||||
'user',
|
|
||||||
'updated',
|
|
||||||
user,
|
user,
|
||||||
eventId,
|
eventId,
|
||||||
txn.amount.toString(),
|
txn.amount.toString(),
|
||||||
referredByContract,
|
referredByContract,
|
||||||
'user',
|
referredByGroup
|
||||||
referredByUser.id,
|
|
||||||
referredByContract?.slug,
|
|
||||||
referredByContract?.question
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
Query,
|
Query,
|
||||||
Transaction,
|
Transaction,
|
||||||
} from 'firebase-admin/firestore'
|
} from 'firebase-admin/firestore'
|
||||||
import { groupBy, mapValues, sumBy } from 'lodash'
|
import { groupBy, mapValues, sumBy, uniq } from 'lodash'
|
||||||
|
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||||
|
@ -153,10 +153,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
log('Main transaction finished.')
|
log('Main transaction finished.')
|
||||||
|
|
||||||
if (result.newBet.amount !== 0) {
|
if (result.newBet.amount !== 0) {
|
||||||
const userIds = [
|
const userIds = uniq([
|
||||||
auth.uid,
|
auth.uid,
|
||||||
...(result.makers ?? []).map((maker) => maker.bet.userId),
|
...(result.makers ?? []).map((maker) => maker.bet.userId),
|
||||||
]
|
])
|
||||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||||
log('Share redemption transaction finished.')
|
log('Share redemption transaction finished.')
|
||||||
}
|
}
|
||||||
|
|
54
functions/src/score-contracts.ts
Normal file
54
functions/src/score-contracts.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { Bet } from 'common/bet'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { log } from './utils'
|
||||||
|
|
||||||
|
export const scoreContracts = functions.pubsub
|
||||||
|
.schedule('every 1 hours')
|
||||||
|
.onRun(async () => {
|
||||||
|
await scoreContractsInternal()
|
||||||
|
})
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function scoreContractsInternal() {
|
||||||
|
const now = Date.now()
|
||||||
|
const lastHour = now - 60 * 60 * 1000
|
||||||
|
const last3Days = now - 1000 * 60 * 60 * 24 * 3
|
||||||
|
const activeContractsSnap = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('lastUpdatedTime', '>', lastHour)
|
||||||
|
.get()
|
||||||
|
const activeContracts = activeContractsSnap.docs.map(
|
||||||
|
(doc) => doc.data() as Contract
|
||||||
|
)
|
||||||
|
// We have to downgrade previously active contracts to allow the new ones to bubble up
|
||||||
|
const previouslyActiveContractsSnap = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('popularityScore', '>', 0)
|
||||||
|
.get()
|
||||||
|
const activeContractIds = activeContracts.map((c) => c.id)
|
||||||
|
const previouslyActiveContracts = previouslyActiveContractsSnap.docs
|
||||||
|
.map((doc) => doc.data() as Contract)
|
||||||
|
.filter((c) => !activeContractIds.includes(c.id))
|
||||||
|
|
||||||
|
const contracts = activeContracts.concat(previouslyActiveContracts)
|
||||||
|
log(`Found ${contracts.length} contracts to score`)
|
||||||
|
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const bets = await firestore
|
||||||
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
|
.where('createdTime', '>', last3Days)
|
||||||
|
.get()
|
||||||
|
const bettors = bets.docs
|
||||||
|
.map((doc) => doc.data() as Bet)
|
||||||
|
.map((bet) => bet.userId)
|
||||||
|
const score = uniq(bettors).length
|
||||||
|
if (contract.popularityScore !== score)
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contract.id)
|
||||||
|
.update({ popularityScore: score })
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,9 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
import { getValues, isProd } from '../utils'
|
import { getValues, isProd } from '../utils'
|
||||||
import {
|
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
|
||||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
import { Group, GroupLink } from 'common/group'
|
||||||
DEFAULT_CATEGORIES,
|
|
||||||
} from 'common/categories'
|
|
||||||
import { Group } from 'common/group'
|
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
@ -18,28 +13,12 @@ import {
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
} from 'common/antes'
|
} from 'common/antes'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
const adminFirestore = admin.firestore()
|
const adminFirestore = admin.firestore()
|
||||||
|
|
||||||
async function convertCategoriesToGroups() {
|
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
|
||||||
const groups = await getValues<Group>(adminFirestore.collection('groups'))
|
for (const category of categories) {
|
||||||
const contracts = await getValues<Contract>(
|
|
||||||
adminFirestore.collection('contracts')
|
|
||||||
)
|
|
||||||
for (const group of groups) {
|
|
||||||
const groupContracts = contracts.filter((contract) =>
|
|
||||||
group.contractIds.includes(contract.id)
|
|
||||||
)
|
|
||||||
for (const contract of groupContracts) {
|
|
||||||
await adminFirestore
|
|
||||||
.collection('contracts')
|
|
||||||
.doc(contract.id)
|
|
||||||
.update({
|
|
||||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const category of Object.values(DEFAULT_CATEGORIES)) {
|
|
||||||
const markets = await getValues<Contract>(
|
const markets = await getValues<Contract>(
|
||||||
adminFirestore
|
adminFirestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -77,7 +56,7 @@ async function convertCategoriesToGroups() {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
anyoneCanJoin: true,
|
anyoneCanJoin: true,
|
||||||
memberIds: [manifoldAccount],
|
memberIds: [manifoldAccount],
|
||||||
about: 'Official group for all things related to ' + category,
|
about: 'Default group for all things related to ' + category,
|
||||||
mostRecentActivityTime: Date.now(),
|
mostRecentActivityTime: Date.now(),
|
||||||
contractIds: markets.map((market) => market.id),
|
contractIds: markets.map((market) => market.id),
|
||||||
chatDisabled: true,
|
chatDisabled: true,
|
||||||
|
@ -93,16 +72,35 @@ async function convertCategoriesToGroups() {
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const market of markets) {
|
for (const market of markets) {
|
||||||
|
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
|
||||||
|
continue // already in that group
|
||||||
|
|
||||||
|
const newGroupLinks = [
|
||||||
|
...(market.groupLinks ?? []),
|
||||||
|
{
|
||||||
|
groupId: newGroup.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
slug: newGroup.slug,
|
||||||
|
name: newGroup.name,
|
||||||
|
} as GroupLink,
|
||||||
|
]
|
||||||
await adminFirestore
|
await adminFirestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(market.id)
|
.doc(market.id)
|
||||||
.update({
|
.update({
|
||||||
groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]),
|
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
|
||||||
|
groupLinks: newGroupLinks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertCategoriesToGroups() {
|
||||||
|
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
|
||||||
|
const moreCategories = ['world', 'culture']
|
||||||
|
await convertCategoriesToGroupsInternal(moreCategories)
|
||||||
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
convertCategoriesToGroups()
|
convertCategoriesToGroups()
|
||||||
.then(() => process.exit())
|
.then(() => process.exit())
|
||||||
|
|
53
functions/src/scripts/link-contracts-to-groups.ts
Normal file
53
functions/src/scripts/link-contracts-to-groups.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { getValues } from 'functions/src/utils'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { initAdmin } from 'functions/src/scripts/script-init'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const adminFirestore = admin.firestore()
|
||||||
|
|
||||||
|
const addGroupIdToContracts = async () => {
|
||||||
|
const groups = await getValues<Group>(adminFirestore.collection('groups'))
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupContracts = await getValues<Contract>(
|
||||||
|
adminFirestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('groupSlugs', 'array-contains', group.slug)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const contract of groupContracts) {
|
||||||
|
const oldGroupLinks = contract.groupLinks?.filter(
|
||||||
|
(l) => l.slug != group.slug
|
||||||
|
)
|
||||||
|
const newGroupLinks = filterDefined([
|
||||||
|
...(oldGroupLinks ?? []),
|
||||||
|
group.id
|
||||||
|
? {
|
||||||
|
slug: group.slug,
|
||||||
|
name: group.name,
|
||||||
|
groupId: group.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
])
|
||||||
|
await adminFirestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contract.id)
|
||||||
|
.update({
|
||||||
|
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||||
|
groupLinks: newGroupLinks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
addGroupIdToContracts()
|
||||||
|
.then(() => process.exit())
|
||||||
|
.catch(console.log)
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { sumBy } from 'lodash'
|
import { sumBy, uniq } from 'lodash'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
@ -7,11 +7,12 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getValues } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { floatingLesserEqual } from '../../common/util/math'
|
import { floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
import { redeemShares } from './redeem-shares'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -23,7 +24,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// Run as transaction to prevent race conditions.
|
// Run as transaction to prevent race conditions.
|
||||||
return await firestore.runTransaction(async (transaction) => {
|
const result = await firestore.runTransaction(async (transaction) => {
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
|
@ -97,8 +98,14 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return { status: 'success' }
|
return { newBet, makers }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||||
|
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||||
|
log('Share redemption transaction finished.')
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
# Ignore Next artifacts
|
# Ignore Next artifacts
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
public/**/*.json
|
210
web/components/NotificationSettings.tsx
Normal file
210
web/components/NotificationSettings.tsx
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
||||||
|
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
|
|
||||||
|
export function NotificationSettings() {
|
||||||
|
const user = useUser()
|
||||||
|
const [notificationSettings, setNotificationSettings] =
|
||||||
|
useState<notification_subscribe_types>('all')
|
||||||
|
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||||
|
useState<notification_subscribe_types>('all')
|
||||||
|
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!privateUser) return
|
||||||
|
if (privateUser.notificationPreferences) {
|
||||||
|
setNotificationSettings(privateUser.notificationPreferences)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
privateUser.unsubscribedFromResolutionEmails &&
|
||||||
|
privateUser.unsubscribedFromCommentEmails &&
|
||||||
|
privateUser.unsubscribedFromAnswerEmails
|
||||||
|
) {
|
||||||
|
setEmailNotificationSettings('none')
|
||||||
|
} else if (
|
||||||
|
!privateUser.unsubscribedFromResolutionEmails &&
|
||||||
|
!privateUser.unsubscribedFromCommentEmails &&
|
||||||
|
!privateUser.unsubscribedFromAnswerEmails
|
||||||
|
) {
|
||||||
|
setEmailNotificationSettings('all')
|
||||||
|
} else {
|
||||||
|
setEmailNotificationSettings('less')
|
||||||
|
}
|
||||||
|
}, [privateUser])
|
||||||
|
|
||||||
|
const loading = 'Changing Notifications Settings'
|
||||||
|
const success = 'Notification Settings Changed!'
|
||||||
|
function changeEmailNotifications(newValue: notification_subscribe_types) {
|
||||||
|
if (!privateUser) return
|
||||||
|
if (newValue === 'all') {
|
||||||
|
toast.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
unsubscribedFromResolutionEmails: false,
|
||||||
|
unsubscribedFromCommentEmails: false,
|
||||||
|
unsubscribedFromAnswerEmails: false,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
error: (err) => `${err.message}`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (newValue === 'less') {
|
||||||
|
toast.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
unsubscribedFromResolutionEmails: false,
|
||||||
|
unsubscribedFromCommentEmails: true,
|
||||||
|
unsubscribedFromAnswerEmails: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
error: (err) => `${err.message}`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (newValue === 'none') {
|
||||||
|
toast.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
unsubscribedFromResolutionEmails: true,
|
||||||
|
unsubscribedFromCommentEmails: true,
|
||||||
|
unsubscribedFromAnswerEmails: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
error: (err) => `${err.message}`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInAppNotificationSettings(
|
||||||
|
newValue: notification_subscribe_types
|
||||||
|
) {
|
||||||
|
if (!privateUser) return
|
||||||
|
track('In-App Notification Preferences Changed', {
|
||||||
|
newPreference: newValue,
|
||||||
|
oldPreference: privateUser.notificationPreferences,
|
||||||
|
})
|
||||||
|
toast.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
notificationPreferences: newValue,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
error: (err) => `${err.message}`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (privateUser && privateUser.notificationPreferences)
|
||||||
|
setNotificationSettings(privateUser.notificationPreferences)
|
||||||
|
else setNotificationSettings('all')
|
||||||
|
}, [privateUser])
|
||||||
|
|
||||||
|
if (!privateUser) {
|
||||||
|
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationSettingLine(props: {
|
||||||
|
label: string
|
||||||
|
highlight: boolean
|
||||||
|
}) {
|
||||||
|
const { label, highlight } = props
|
||||||
|
return (
|
||||||
|
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
||||||
|
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||||
|
{label}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'p-2'}>
|
||||||
|
<div>In App Notifications</div>
|
||||||
|
<ChoicesToggleGroup
|
||||||
|
currentChoice={notificationSettings}
|
||||||
|
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
||||||
|
setChoice={(choice) =>
|
||||||
|
changeInAppNotificationSettings(
|
||||||
|
choice as notification_subscribe_types
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={'col-span-4 p-2'}
|
||||||
|
toggleClassName={'w-24'}
|
||||||
|
/>
|
||||||
|
<div className={'mt-4 text-sm'}>
|
||||||
|
<div>
|
||||||
|
<div className={''}>
|
||||||
|
You will receive notifications for:
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={"Resolution of questions you've interacted with"}
|
||||||
|
highlight={notificationSettings !== 'none'}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
highlight={notificationSettings !== 'none'}
|
||||||
|
label={'Activity on your own questions, comments, & answers'}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
highlight={notificationSettings !== 'none'}
|
||||||
|
label={"Activity on questions you're betting on"}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
highlight={notificationSettings !== 'none'}
|
||||||
|
label={"Income & referral bonuses you've received"}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={"Activity on questions you've ever bet or commented on"}
|
||||||
|
highlight={notificationSettings === 'all'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={'mt-4'}>Email Notifications</div>
|
||||||
|
<ChoicesToggleGroup
|
||||||
|
currentChoice={emailNotificationSettings}
|
||||||
|
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
||||||
|
setChoice={(choice) =>
|
||||||
|
changeEmailNotifications(choice as notification_subscribe_types)
|
||||||
|
}
|
||||||
|
className={'col-span-4 p-2'}
|
||||||
|
toggleClassName={'w-24'}
|
||||||
|
/>
|
||||||
|
<div className={'mt-4 text-sm'}>
|
||||||
|
<div>
|
||||||
|
You will receive emails for:
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={"Resolution of questions you're betting on"}
|
||||||
|
highlight={emailNotificationSettings !== 'none'}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={'Closure of your questions'}
|
||||||
|
highlight={emailNotificationSettings !== 'none'}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={'Activity on your questions'}
|
||||||
|
highlight={emailNotificationSettings === 'all'}
|
||||||
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
label={"Activity on questions you've answered or commented on"}
|
||||||
|
highlight={emailNotificationSettings === 'all'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -41,7 +41,7 @@ export function AmountInput(props: {
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
<span className="bg-gray-200 text-sm">{label}</span>
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'input input-bordered max-w-[200px] text-lg',
|
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
|
77
web/components/auth-context.tsx
Normal file
77
web/components/auth-context.tsx
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { createContext, useEffect } from 'react'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { onIdTokenChanged } from 'firebase/auth'
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
listenForUser,
|
||||||
|
getUser,
|
||||||
|
setCachedReferralInfoForUser,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
|
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
|
||||||
|
import { createUser } from 'web/lib/firebase/api'
|
||||||
|
import { randomString } from 'common/util/random'
|
||||||
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
|
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
||||||
|
|
||||||
|
// Either we haven't looked up the logged in user yet (undefined), or we know
|
||||||
|
// the user is not logged in (null), or we know the user is logged in (User).
|
||||||
|
type AuthUser = undefined | null | User
|
||||||
|
|
||||||
|
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
||||||
|
|
||||||
|
const ensureDeviceToken = () => {
|
||||||
|
let deviceToken = localStorage.getItem('device-token')
|
||||||
|
if (!deviceToken) {
|
||||||
|
deviceToken = randomString()
|
||||||
|
localStorage.setItem('device-token', deviceToken)
|
||||||
|
}
|
||||||
|
return deviceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthUser>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: any) {
|
||||||
|
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
||||||
|
setAuthUser(cachedUser && JSON.parse(cachedUser))
|
||||||
|
}, [setAuthUser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onIdTokenChanged(auth, async (fbUser) => {
|
||||||
|
if (fbUser) {
|
||||||
|
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
|
||||||
|
let user = await getUser(fbUser.uid)
|
||||||
|
if (!user) {
|
||||||
|
const deviceToken = ensureDeviceToken()
|
||||||
|
user = (await createUser({ deviceToken })) as User
|
||||||
|
}
|
||||||
|
setAuthUser(user)
|
||||||
|
// Persist to local storage, to reduce login blink next time.
|
||||||
|
// Note: Cap on localStorage size is ~5mb
|
||||||
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
|
||||||
|
setCachedReferralInfoForUser(user)
|
||||||
|
} else {
|
||||||
|
// User logged out; reset to null
|
||||||
|
deleteAuthCookies()
|
||||||
|
setAuthUser(null)
|
||||||
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [setAuthUser])
|
||||||
|
|
||||||
|
const authUserId = authUser?.id
|
||||||
|
const authUsername = authUser?.username
|
||||||
|
useEffect(() => {
|
||||||
|
if (authUserId && authUsername) {
|
||||||
|
identifyUser(authUserId)
|
||||||
|
setUserProperty('username', authUsername)
|
||||||
|
return listenForUser(authUserId, setAuthUser)
|
||||||
|
}
|
||||||
|
}, [authUserId, authUsername, setAuthUser])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ export function Avatar(props: {
|
||||||
!noLink && 'cursor-pointer',
|
!noLink && 'cursor-pointer',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{ maxWidth: `${s * 0.25}rem` }}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
alt={username}
|
alt={username}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { partition, sum, sumBy } from 'lodash'
|
import { clamp, partition, sum, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
@ -13,33 +13,35 @@ import {
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
} from 'common/util/format'
|
} from 'common/util/format'
|
||||||
import { getBinaryCpmmBetInfo } from 'common/new-bet'
|
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import { Bet, LimitBet } from 'common/bet'
|
import { Bet, LimitBet } from 'common/bet'
|
||||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||||
import { sellShares } from 'web/lib/firebase/api'
|
import { sellShares } from 'web/lib/firebase/api'
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
import { InfoTooltip } from './info-tooltip'
|
||||||
import { BinaryOutcomeLabel } from './outcome-label'
|
import {
|
||||||
|
BinaryOutcomeLabel,
|
||||||
|
HigherLabel,
|
||||||
|
LowerLabel,
|
||||||
|
NoLabel,
|
||||||
|
YesLabel,
|
||||||
|
} from './outcome-label'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { useFocus } from 'web/hooks/use-focus'
|
import { useFocus } from 'web/hooks/use-focus'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
import {
|
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||||
getFormattedMappedValue,
|
|
||||||
getPseudoProbability,
|
|
||||||
} from 'common/pseudo-numeric'
|
|
||||||
import { SellRow } from './sell-row'
|
import { SellRow } from './sell-row'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { SignUpPrompt } from './sign-up-prompt'
|
import { SignUpPrompt } from './sign-up-prompt'
|
||||||
import { isIOS } from 'web/lib/util/device'
|
import { isIOS } from 'web/lib/util/device'
|
||||||
import { ProbabilityInput } from './probability-input'
|
import { ProbabilityOrNumericInput } from './probability-input'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
import { LimitBets } from './limit-bets'
|
import { LimitBets } from './limit-bets'
|
||||||
import { BucketInput } from './bucket-input'
|
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -49,14 +51,10 @@ export function BetPanel(props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const userBets = useUserContractBets(user?.id, contract.id)
|
const userBets = useUserContractBets(user?.id, contract.id)
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
|
|
||||||
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
||||||
|
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
|
||||||
const showLimitOrders =
|
|
||||||
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<SellRow
|
<SellRow
|
||||||
|
@ -76,15 +74,20 @@ export function BetPanel(props: {
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
/>
|
/>
|
||||||
<BuyPanel
|
<BuyPanel
|
||||||
|
hidden={isLimitOrder}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
/>
|
||||||
|
<LimitOrderPanel
|
||||||
|
hidden={!isLimitOrder}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
isLimitOrder={isLimitOrder}
|
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignUpPrompt />
|
<SignUpPrompt />
|
||||||
</Col>
|
</Col>
|
||||||
{showLimitOrders && (
|
{unfilledBets.length > 0 && (
|
||||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -104,9 +107,6 @@ export function SimpleBetPanel(props: {
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
|
|
||||||
const showLimitOrders =
|
|
||||||
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
|
@ -126,18 +126,24 @@ export function SimpleBetPanel(props: {
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
/>
|
/>
|
||||||
<BuyPanel
|
<BuyPanel
|
||||||
|
hidden={isLimitOrder}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
isLimitOrder={isLimitOrder}
|
|
||||||
/>
|
/>
|
||||||
|
<LimitOrderPanel
|
||||||
|
hidden={!isLimitOrder}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
onBuySuccess={onBetSuccess}
|
||||||
|
/>
|
||||||
<SignUpPrompt />
|
<SignUpPrompt />
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{showLimitOrders && (
|
{unfilledBets.length > 0 && (
|
||||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -148,21 +154,17 @@ function BuyPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
unfilledBets: Bet[]
|
unfilledBets: Bet[]
|
||||||
isLimitOrder?: boolean
|
hidden: boolean
|
||||||
selected?: 'YES' | 'NO'
|
selected?: 'YES' | 'NO'
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } =
|
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
|
||||||
props
|
|
||||||
|
|
||||||
const initialProb = getProbability(contract)
|
const initialProb = getProbability(contract)
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
|
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
|
||||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||||
const [limitProb, setLimitProb] = useState<number | undefined>(
|
|
||||||
Math.round(100 * initialProb)
|
|
||||||
)
|
|
||||||
const [error, setError] = useState<string | undefined>()
|
const [error, setError] = useState<string | undefined>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||||
|
@ -177,7 +179,7 @@ function BuyPanel(props: {
|
||||||
}, [selected, focusAmountInput])
|
}, [selected, focusAmountInput])
|
||||||
|
|
||||||
function onBetChoice(choice: 'YES' | 'NO') {
|
function onBetChoice(choice: 'YES' | 'NO') {
|
||||||
setBetChoice(choice)
|
setOutcome(choice)
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
focusAmountInput()
|
focusAmountInput()
|
||||||
}
|
}
|
||||||
|
@ -185,29 +187,22 @@ function BuyPanel(props: {
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
setBetAmount(newAmount)
|
setBetAmount(newAmount)
|
||||||
if (!betChoice) {
|
if (!outcome) {
|
||||||
setBetChoice('YES')
|
setOutcome('YES')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBet() {
|
async function submitBet() {
|
||||||
if (!user || !betAmount) return
|
if (!user || !betAmount) return
|
||||||
if (isLimitOrder && limitProb === undefined) return
|
|
||||||
|
|
||||||
const limitProbScaled =
|
|
||||||
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
|
|
||||||
|
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
placeBet(
|
placeBet({
|
||||||
removeUndefinedProps({
|
outcome,
|
||||||
amount: betAmount,
|
amount: betAmount,
|
||||||
outcome: betChoice,
|
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
limitProb: limitProbScaled,
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log('placed bet. Result:', r)
|
console.log('placed bet. Result:', r)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
@ -231,21 +226,18 @@ function BuyPanel(props: {
|
||||||
slug: contract.slug,
|
slug: contract.slug,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount: betAmount,
|
amount: betAmount,
|
||||||
outcome: betChoice,
|
outcome,
|
||||||
isLimitOrder,
|
isLimitOrder: false,
|
||||||
limitProb: limitProbScaled,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const betDisabled = isSubmitting || !betAmount || error
|
const betDisabled = isSubmitting || !betAmount || error
|
||||||
|
|
||||||
const limitProbFrac = (limitProb ?? 0) / 100
|
|
||||||
|
|
||||||
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
|
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
|
||||||
betChoice ?? 'YES',
|
outcome ?? 'YES',
|
||||||
betAmount ?? 0,
|
betAmount ?? 0,
|
||||||
contract,
|
contract,
|
||||||
isLimitOrder ? limitProbFrac : undefined,
|
undefined,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets as LimitBet[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -253,11 +245,7 @@ function BuyPanel(props: {
|
||||||
const probStayedSame =
|
const probStayedSame =
|
||||||
formatPercent(resultProb) === formatPercent(initialProb)
|
formatPercent(resultProb) === formatPercent(initialProb)
|
||||||
|
|
||||||
const remainingMatched = isLimitOrder
|
const currentPayout = newBet.shares
|
||||||
? ((newBet.orderAmount ?? 0) - newBet.amount) /
|
|
||||||
(betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
|
|
||||||
: 0
|
|
||||||
const currentPayout = newBet.shares + remainingMatched
|
|
||||||
|
|
||||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
const currentReturnPercent = formatPercent(currentReturn)
|
const currentReturnPercent = formatPercent(currentReturn)
|
||||||
|
@ -267,26 +255,17 @@ function BuyPanel(props: {
|
||||||
const format = getFormattedMappedValue(contract)
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Direction</div>
|
<div className="my-3 text-left text-sm text-gray-500">
|
||||||
<Row className="mb-4 items-center gap-2">
|
{isPseudoNumeric ? 'Direction' : 'Outcome'}
|
||||||
<PillButton
|
</div>
|
||||||
selected={betChoice === 'YES'}
|
<YesNoSelector
|
||||||
onSelect={() => onBetChoice('YES')}
|
className="mb-4"
|
||||||
big
|
btnClassName="flex-1"
|
||||||
color="bg-primary"
|
selected={outcome}
|
||||||
>
|
onSelect={(choice) => onBetChoice(choice)}
|
||||||
{isPseudoNumeric ? 'Higher' : 'Yes'}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
</PillButton>
|
/>
|
||||||
<PillButton
|
|
||||||
selected={betChoice === 'NO'}
|
|
||||||
onSelect={() => onBetChoice('NO')}
|
|
||||||
big
|
|
||||||
color="bg-red-400"
|
|
||||||
>
|
|
||||||
{isPseudoNumeric ? 'Lower' : 'No'}
|
|
||||||
</PillButton>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
|
@ -298,46 +277,7 @@ function BuyPanel(props: {
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
/>
|
/>
|
||||||
{isLimitOrder && (
|
|
||||||
<>
|
|
||||||
<Row className="my-3 items-center gap-2 text-left text-sm text-gray-500">
|
|
||||||
Limit {isPseudoNumeric ? 'value' : 'probability'}
|
|
||||||
<InfoTooltip
|
|
||||||
text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${
|
|
||||||
isPseudoNumeric ? 'value' : 'probability'
|
|
||||||
} and wait to match other bets.`}
|
|
||||||
/>
|
|
||||||
</Row>
|
|
||||||
{isPseudoNumeric ? (
|
|
||||||
<BucketInput
|
|
||||||
contract={contract}
|
|
||||||
onBucketChange={(value) =>
|
|
||||||
setLimitProb(
|
|
||||||
value === undefined
|
|
||||||
? undefined
|
|
||||||
: 100 *
|
|
||||||
getPseudoProbability(
|
|
||||||
value,
|
|
||||||
contract.min,
|
|
||||||
contract.max,
|
|
||||||
contract.isLogScale
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ProbabilityInput
|
|
||||||
inputClassName="w-full max-w-none"
|
|
||||||
prob={limitProb}
|
|
||||||
onChange={setLimitProb}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
{!isLimitOrder && (
|
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||||
|
@ -352,7 +292,6 @@ function BuyPanel(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
|
||||||
|
|
||||||
<Row className="items-center justify-between gap-2 text-sm">
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
||||||
|
@ -361,7 +300,7 @@ function BuyPanel(props: {
|
||||||
'Max payout'
|
'Max payout'
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -386,7 +325,7 @@ function BuyPanel(props: {
|
||||||
'btn flex-1',
|
'btn flex-1',
|
||||||
betDisabled
|
betDisabled
|
||||||
? 'btn-disabled'
|
? 'btn-disabled'
|
||||||
: betChoice === 'YES'
|
: outcome === 'YES'
|
||||||
? 'btn-primary'
|
? 'btn-primary'
|
||||||
: 'border-none bg-red-400 hover:bg-red-500',
|
: 'border-none bg-red-400 hover:bg-red-500',
|
||||||
isSubmitting ? 'loading' : ''
|
isSubmitting ? 'loading' : ''
|
||||||
|
@ -397,10 +336,352 @@ function BuyPanel(props: {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wasSubmitted && (
|
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
|
||||||
<div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div>
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitOrderPanel(props: {
|
||||||
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
user: User | null | undefined
|
||||||
|
unfilledBets: Bet[]
|
||||||
|
hidden: boolean
|
||||||
|
onBuySuccess?: () => void
|
||||||
|
}) {
|
||||||
|
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
|
||||||
|
|
||||||
|
const initialProb = getProbability(contract)
|
||||||
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
|
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||||
|
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
|
||||||
|
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
|
||||||
|
const betChoice = 'YES'
|
||||||
|
const [error, setError] = useState<string | undefined>()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||||
|
|
||||||
|
const rangeError =
|
||||||
|
lowLimitProb !== undefined &&
|
||||||
|
highLimitProb !== undefined &&
|
||||||
|
lowLimitProb >= highLimitProb
|
||||||
|
|
||||||
|
const outOfRangeError =
|
||||||
|
(lowLimitProb !== undefined &&
|
||||||
|
(lowLimitProb <= 0 || lowLimitProb >= 100)) ||
|
||||||
|
(highLimitProb !== undefined &&
|
||||||
|
(highLimitProb <= 0 || highLimitProb >= 100))
|
||||||
|
|
||||||
|
const hasYesLimitBet = lowLimitProb !== undefined && !!betAmount
|
||||||
|
const hasNoLimitBet = highLimitProb !== undefined && !!betAmount
|
||||||
|
const hasTwoBets = hasYesLimitBet && hasNoLimitBet
|
||||||
|
|
||||||
|
const betDisabled =
|
||||||
|
isSubmitting ||
|
||||||
|
!betAmount ||
|
||||||
|
rangeError ||
|
||||||
|
outOfRangeError ||
|
||||||
|
error ||
|
||||||
|
(!hasYesLimitBet && !hasNoLimitBet)
|
||||||
|
|
||||||
|
const yesLimitProb =
|
||||||
|
lowLimitProb === undefined
|
||||||
|
? undefined
|
||||||
|
: clamp(lowLimitProb / 100, 0.001, 0.999)
|
||||||
|
const noLimitProb =
|
||||||
|
highLimitProb === undefined
|
||||||
|
? undefined
|
||||||
|
: clamp(highLimitProb / 100, 0.001, 0.999)
|
||||||
|
|
||||||
|
const amount = betAmount ?? 0
|
||||||
|
const shares =
|
||||||
|
yesLimitProb !== undefined && noLimitProb !== undefined
|
||||||
|
? Math.min(amount / yesLimitProb, amount / (1 - noLimitProb))
|
||||||
|
: yesLimitProb !== undefined
|
||||||
|
? amount / yesLimitProb
|
||||||
|
: noLimitProb !== undefined
|
||||||
|
? amount / (1 - noLimitProb)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const yesAmount = shares * (yesLimitProb ?? 1)
|
||||||
|
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||||
|
|
||||||
|
const profitIfBothFilled = shares - (yesAmount + noAmount)
|
||||||
|
|
||||||
|
function onBetChange(newAmount: number | undefined) {
|
||||||
|
setWasSubmitted(false)
|
||||||
|
setBetAmount(newAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBet() {
|
||||||
|
if (!user || betDisabled) return
|
||||||
|
|
||||||
|
setError(undefined)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
const betsPromise = hasTwoBets
|
||||||
|
? Promise.all([
|
||||||
|
placeBet({
|
||||||
|
outcome: 'YES',
|
||||||
|
amount: yesAmount,
|
||||||
|
limitProb: yesLimitProb,
|
||||||
|
contractId: contract.id,
|
||||||
|
}),
|
||||||
|
placeBet({
|
||||||
|
outcome: 'NO',
|
||||||
|
amount: noAmount,
|
||||||
|
limitProb: noLimitProb,
|
||||||
|
contractId: contract.id,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: placeBet({
|
||||||
|
outcome: hasYesLimitBet ? 'YES' : 'NO',
|
||||||
|
amount: betAmount,
|
||||||
|
contractId: contract.id,
|
||||||
|
limitProb: hasYesLimitBet ? yesLimitProb : noLimitProb,
|
||||||
|
})
|
||||||
|
|
||||||
|
betsPromise
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof APIError) {
|
||||||
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error placing bet')
|
||||||
|
}
|
||||||
|
setIsSubmitting(false)
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
console.log('placed bet. Result:', r)
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setWasSubmitted(true)
|
||||||
|
setBetAmount(undefined)
|
||||||
|
if (onBuySuccess) onBuySuccess()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasYesLimitBet) {
|
||||||
|
track('bet', {
|
||||||
|
location: 'bet panel',
|
||||||
|
outcomeType: contract.outcomeType,
|
||||||
|
slug: contract.slug,
|
||||||
|
contractId: contract.id,
|
||||||
|
amount: yesAmount,
|
||||||
|
outcome: 'YES',
|
||||||
|
limitProb: yesLimitProb,
|
||||||
|
isLimitOrder: true,
|
||||||
|
isRangeOrder: hasTwoBets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (hasNoLimitBet) {
|
||||||
|
track('bet', {
|
||||||
|
location: 'bet panel',
|
||||||
|
outcomeType: contract.outcomeType,
|
||||||
|
slug: contract.slug,
|
||||||
|
contractId: contract.id,
|
||||||
|
amount: noAmount,
|
||||||
|
outcome: 'NO',
|
||||||
|
limitProb: noLimitProb,
|
||||||
|
isLimitOrder: true,
|
||||||
|
isRangeOrder: hasTwoBets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPayout: yesPayout,
|
||||||
|
currentReturn: yesReturn,
|
||||||
|
totalFees: yesFees,
|
||||||
|
newBet: yesBet,
|
||||||
|
} = getBinaryBetStats(
|
||||||
|
'YES',
|
||||||
|
yesAmount,
|
||||||
|
contract,
|
||||||
|
yesLimitProb ?? initialProb,
|
||||||
|
unfilledBets as LimitBet[]
|
||||||
|
)
|
||||||
|
const yesReturnPercent = formatPercent(yesReturn)
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPayout: noPayout,
|
||||||
|
currentReturn: noReturn,
|
||||||
|
totalFees: noFees,
|
||||||
|
newBet: noBet,
|
||||||
|
} = getBinaryBetStats(
|
||||||
|
'NO',
|
||||||
|
noAmount,
|
||||||
|
contract,
|
||||||
|
noLimitProb ?? initialProb,
|
||||||
|
unfilledBets as LimitBet[]
|
||||||
|
)
|
||||||
|
const noReturnPercent = formatPercent(noReturn)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
|
<Row className="mt-1 items-center gap-4">
|
||||||
|
<Col className="gap-2">
|
||||||
|
<div className="relative ml-1 text-sm text-gray-500">
|
||||||
|
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
|
||||||
|
</div>
|
||||||
|
<ProbabilityOrNumericInput
|
||||||
|
contract={contract}
|
||||||
|
prob={lowLimitProb}
|
||||||
|
setProb={setLowLimitProb}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col className="gap-2">
|
||||||
|
<div className="ml-1 text-sm text-gray-500">
|
||||||
|
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
|
||||||
|
</div>
|
||||||
|
<ProbabilityOrNumericInput
|
||||||
|
contract={contract}
|
||||||
|
prob={highLimitProb}
|
||||||
|
setProb={setHighLimitProb}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{outOfRangeError && (
|
||||||
|
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||||
|
Limit is out of range
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{rangeError && !outOfRangeError && (
|
||||||
|
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||||
|
{isPseudoNumeric ? 'HIGHER' : 'YES'} limit must be less than{' '}
|
||||||
|
{isPseudoNumeric ? 'LOWER' : 'NO'} limit
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
|
||||||
|
Max amount<span className="ml-1 text-red-500">*</span>
|
||||||
|
</div>
|
||||||
|
<BuyAmountInput
|
||||||
|
inputClassName="w-full max-w-none"
|
||||||
|
amount={betAmount}
|
||||||
|
onChange={onBetChange}
|
||||||
|
error={error}
|
||||||
|
setError={setError}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Col className="mt-3 w-full gap-3">
|
||||||
|
{(hasTwoBets || (hasYesLimitBet && yesBet.amount !== 0)) && (
|
||||||
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
|
<div className="whitespace-nowrap text-gray-500">
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
<HigherLabel />
|
||||||
|
) : (
|
||||||
|
<BinaryOutcomeLabel outcome={'YES'} />
|
||||||
|
)}{' '}
|
||||||
|
filled now
|
||||||
|
</div>
|
||||||
|
<div className="mr-2 whitespace-nowrap">
|
||||||
|
{formatMoney(yesBet.amount)} of{' '}
|
||||||
|
{formatMoney(yesBet.orderAmount ?? 0)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{(hasTwoBets || (hasNoLimitBet && noBet.amount !== 0)) && (
|
||||||
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
|
<div className="whitespace-nowrap text-gray-500">
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
<LowerLabel />
|
||||||
|
) : (
|
||||||
|
<BinaryOutcomeLabel outcome={'NO'} />
|
||||||
|
)}{' '}
|
||||||
|
filled now
|
||||||
|
</div>
|
||||||
|
<div className="mr-2 whitespace-nowrap">
|
||||||
|
{formatMoney(noBet.amount)} of{' '}
|
||||||
|
{formatMoney(noBet.orderAmount ?? 0)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{hasTwoBets && (
|
||||||
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
|
<div className="whitespace-nowrap text-gray-500">
|
||||||
|
Profit if both orders filled
|
||||||
|
</div>
|
||||||
|
<div className="mr-2 whitespace-nowrap">
|
||||||
|
{formatMoney(profitIfBothFilled)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{hasYesLimitBet && !hasTwoBets && (
|
||||||
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
|
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
||||||
|
<div>
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
'Max payout'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Max <BinaryOutcomeLabel outcome={'YES'} /> payout
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<InfoTooltip
|
||||||
|
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
{formatMoney(yesPayout)}
|
||||||
|
</span>
|
||||||
|
(+{yesReturnPercent})
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{hasNoLimitBet && !hasTwoBets && (
|
||||||
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
|
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
||||||
|
<div>
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
'Max payout'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Max <BinaryOutcomeLabel outcome={'NO'} /> payout
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<InfoTooltip
|
||||||
|
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
{formatMoney(noPayout)}
|
||||||
|
</span>
|
||||||
|
(+{noReturnPercent})
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'btn flex-1',
|
||||||
|
betDisabled
|
||||||
|
? 'btn-disabled'
|
||||||
|
: betChoice === 'YES'
|
||||||
|
? 'btn-primary'
|
||||||
|
: 'border-none bg-red-400 hover:bg-red-500',
|
||||||
|
isSubmitting ? 'loading' : ''
|
||||||
|
)}
|
||||||
|
onClick={betDisabled ? undefined : submitBet}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? 'Submitting...'
|
||||||
|
: `Submit order${hasTwoBets ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
|
||||||
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -413,7 +694,7 @@ function QuickOrLimitBet(props: {
|
||||||
return (
|
return (
|
||||||
<Row className="align-center mb-4 justify-between">
|
<Row className="align-center mb-4 justify-between">
|
||||||
<div className="text-4xl">Bet</div>
|
<div className="text-4xl">Bet</div>
|
||||||
<Row className="mt-2 items-center gap-2">
|
<Row className="mt-1 items-center gap-2">
|
||||||
<PillButton
|
<PillButton
|
||||||
selected={!isLimitOrder}
|
selected={!isLimitOrder}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|
|
@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets'
|
||||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
const CONTRACTS_PER_PAGE = 20
|
const CONTRACTS_PER_PAGE = 50
|
||||||
|
|
||||||
export function BetsList(props: {
|
export function BetsList(props: {
|
||||||
user: User
|
user: User
|
||||||
|
@ -78,10 +78,10 @@ export function BetsList(props: {
|
||||||
|
|
||||||
const getTime = useTimeSinceFirstRender()
|
const getTime = useTimeSinceFirstRender()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bets && contractsById) {
|
if (bets && contractsById && signedInUser) {
|
||||||
trackLatency('portfolio', getTime())
|
trackLatency(signedInUser.id, 'portfolio', getTime())
|
||||||
}
|
}
|
||||||
}, [bets, contractsById, getTime])
|
}, [signedInUser, bets, contractsById, getTime])
|
||||||
|
|
||||||
if (!bets || !contractsById) {
|
if (!bets || !contractsById) {
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
|
|
|
@ -9,8 +9,9 @@ export function BucketInput(props: {
|
||||||
contract: NumericContract | PseudoNumericContract
|
contract: NumericContract | PseudoNumericContract
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
onBucketChange: (value?: number, bucket?: string) => void
|
onBucketChange: (value?: number, bucket?: string) => void
|
||||||
|
placeholder?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, isSubmitting, onBucketChange } = props
|
const { contract, isSubmitting, onBucketChange, placeholder } = props
|
||||||
|
|
||||||
const [numberString, setNumberString] = useState('')
|
const [numberString, setNumberString] = useState('')
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ export function BucketInput(props: {
|
||||||
error={undefined}
|
error={undefined}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
numberString={numberString}
|
numberString={numberString}
|
||||||
label="Value"
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,49 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export default function Button(props: {
|
export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
color: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray'
|
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
|
||||||
type?: 'button' | 'reset' | 'submit'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
children,
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
size = 'md',
|
||||||
color = 'indigo',
|
color = 'indigo',
|
||||||
type = 'button',
|
type = 'button',
|
||||||
|
disabled = false,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: 'px-2.5 py-1.5 text-sm',
|
||||||
|
sm: 'px-3 py-2 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-4 py-2 text-base',
|
||||||
|
xl: 'px-6 py-3 text-base',
|
||||||
|
}[size]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'font-md items-center justify-center rounded-md border border-transparent px-4 py-2 shadow-sm hover:transition-colors',
|
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
sizeClasses,
|
||||||
color === 'green' && 'btn-primary text-white',
|
color === 'green' && 'btn-primary text-white',
|
||||||
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
|
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
|
||||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300',
|
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||||
|
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export function PillButton(props: {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer select-none rounded-full',
|
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||||
selected
|
selected
|
||||||
? ['text-white', color ?? 'bg-gray-700']
|
? ['text-white', color ?? 'bg-gray-700']
|
||||||
: 'bg-gray-100 hover:bg-gray-200',
|
: 'bg-gray-100 hover:bg-gray-200',
|
||||||
|
|
|
@ -6,10 +6,9 @@ import { Charity } from 'common/charity'
|
||||||
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
||||||
import { manaToUSD } from '../../../common/util/format'
|
import { manaToUSD } from '../../../common/util/format'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Col } from '../layout/col'
|
|
||||||
|
|
||||||
export function CharityCard(props: { charity: Charity; match?: number }) {
|
export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
const { charity, match } = props
|
const { charity } = props
|
||||||
const { slug, photo, preview, id, tags } = charity
|
const { slug, photo, preview, id, tags } = charity
|
||||||
|
|
||||||
const txns = useCharityTxns(id)
|
const txns = useCharityTxns(id)
|
||||||
|
@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
{raised > 0 && (
|
{raised > 0 && (
|
||||||
<>
|
<>
|
||||||
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
|
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
|
||||||
<Col>
|
<Row className="items-baseline gap-1">
|
||||||
<span className="text-3xl font-semibold">
|
<span className="text-3xl font-semibold">
|
||||||
{formatUsd(raised)}
|
{formatUsd(raised)}
|
||||||
</span>
|
</span>
|
||||||
<span>raised</span>
|
raised
|
||||||
</Col>
|
</Row>
|
||||||
{match && (
|
{/* {match && (
|
||||||
<Col className="text-gray-500">
|
<Col className="text-gray-500">
|
||||||
<span className="text-xl">+{formatUsd(match)}</span>
|
<span className="text-xl">+{formatUsd(match)}</span>
|
||||||
<span className="">match</span>
|
<span className="">match</span>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)} */}
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -22,10 +22,13 @@ import { Spacer } from './layout/spacer'
|
||||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { track, trackCallback } from 'web/lib/service/analytics'
|
||||||
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { NEW_USER_GROUP_SLUGS } from 'common/group'
|
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
||||||
|
import { PillButton } from './buttons/pill-button'
|
||||||
|
import { sortBy } from 'lodash'
|
||||||
|
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||||
|
|
||||||
const searchClient = algoliasearch(
|
const searchClient = algoliasearch(
|
||||||
'GJQPAYENIF',
|
'GJQPAYENIF',
|
||||||
|
@ -36,14 +39,16 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||||
|
|
||||||
const sortIndexes = [
|
const sortIndexes = [
|
||||||
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
||||||
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
||||||
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' },
|
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
|
||||||
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
||||||
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
||||||
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
||||||
|
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
|
||||||
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
||||||
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
||||||
]
|
]
|
||||||
|
export const DEFAULT_SORT = 'score'
|
||||||
|
|
||||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
|
@ -76,9 +81,24 @@ export function ContractSearch(props: {
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const memberGroupSlugs = useMemberGroups(user?.id)
|
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||||
?.map((g) => g.slug)
|
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
||||||
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
|
)
|
||||||
|
const memberGroupSlugs =
|
||||||
|
memberGroups.length > 0
|
||||||
|
? memberGroups.map((g) => g.slug)
|
||||||
|
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
||||||
|
|
||||||
|
const memberPillGroups = sortBy(
|
||||||
|
memberGroups.filter((group) => group.contractIds.length > 0),
|
||||||
|
(group) => group.contractIds.length
|
||||||
|
).reverse()
|
||||||
|
|
||||||
|
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
|
||||||
|
|
||||||
|
const pillGroups =
|
||||||
|
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
||||||
|
|
||||||
const follows = useFollows(user?.id)
|
const follows = useFollows(user?.id)
|
||||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
||||||
|
|
||||||
|
@ -86,34 +106,51 @@ export function ContractSearch(props: {
|
||||||
.map(({ value }) => value)
|
.map(({ value }) => value)
|
||||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
||||||
? initialSort
|
? initialSort
|
||||||
: querySortOptions?.defaultSort ?? 'most-popular'
|
: querySortOptions?.defaultSort ?? DEFAULT_SORT
|
||||||
|
|
||||||
const [filter, setFilter] = useState<filter>(
|
const [filter, setFilter] = useState<filter>(
|
||||||
querySortOptions?.defaultFilter ?? 'open'
|
querySortOptions?.defaultFilter ?? 'open'
|
||||||
)
|
)
|
||||||
|
const pillsEnabled = !additionalFilter
|
||||||
|
|
||||||
|
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const selectFilter = (pill: string | undefined) => () => {
|
||||||
|
setPillFilter(pill)
|
||||||
|
track('select search category', { category: pill ?? 'all' })
|
||||||
|
}
|
||||||
|
|
||||||
const { filters, numericFilters } = useMemo(() => {
|
const { filters, numericFilters } = useMemo(() => {
|
||||||
let filters = [
|
let filters = [
|
||||||
filter === 'open' ? 'isResolved:false' : '',
|
filter === 'open' ? 'isResolved:false' : '',
|
||||||
filter === 'closed' ? 'isResolved:false' : '',
|
filter === 'closed' ? 'isResolved:false' : '',
|
||||||
filter === 'resolved' ? 'isResolved:true' : '',
|
filter === 'resolved' ? 'isResolved:true' : '',
|
||||||
filter === 'personal'
|
additionalFilter?.creatorId
|
||||||
|
? `creatorId:${additionalFilter.creatorId}`
|
||||||
|
: '',
|
||||||
|
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||||
|
additionalFilter?.groupSlug
|
||||||
|
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||||
|
: '',
|
||||||
|
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||||
|
? `groupLinks.slug:${pillFilter}`
|
||||||
|
: '',
|
||||||
|
pillFilter === 'personal'
|
||||||
? // Show contracts in groups that the user is a member of
|
? // Show contracts in groups that the user is a member of
|
||||||
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
|
memberGroupSlugs
|
||||||
|
.map((slug) => `groupLinks.slug:${slug}`)
|
||||||
// Show contracts created by users the user follows
|
// Show contracts created by users the user follows
|
||||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
||||||
// Show contracts bet on by users the user follows
|
// Show contracts bet on by users the user follows
|
||||||
.concat(
|
.concat(
|
||||||
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
||||||
// Show contracts bet on by the user
|
|
||||||
)
|
)
|
||||||
.concat(user ? `uniqueBettorIds:${user.id}` : [])
|
|
||||||
: '',
|
: '',
|
||||||
additionalFilter?.creatorId
|
// Subtract contracts you bet on from For you.
|
||||||
? `creatorId:${additionalFilter.creatorId}`
|
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
|
||||||
: '',
|
pillFilter === 'your-bets' && user
|
||||||
additionalFilter?.groupSlug
|
? // Show contracts bet on by the user
|
||||||
? `groupSlugs:${additionalFilter.groupSlug}`
|
`uniqueBettorIds:${user.id}`
|
||||||
: '',
|
: '',
|
||||||
].filter((f) => f)
|
].filter((f) => f)
|
||||||
// Hack to make Algolia work.
|
// Hack to make Algolia work.
|
||||||
|
@ -128,8 +165,9 @@ export function ContractSearch(props: {
|
||||||
}, [
|
}, [
|
||||||
filter,
|
filter,
|
||||||
Object.values(additionalFilter ?? {}).join(','),
|
Object.values(additionalFilter ?? {}).join(','),
|
||||||
(memberGroupSlugs ?? []).join(','),
|
memberGroupSlugs.join(','),
|
||||||
(follows ?? []).join(','),
|
(follows ?? []).join(','),
|
||||||
|
pillFilter,
|
||||||
])
|
])
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
|
@ -160,12 +198,11 @@ export function ContractSearch(props: {
|
||||||
className="!select !select-bordered"
|
className="!select !select-bordered"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value as filter)}
|
onChange={(e) => setFilter(e.target.value as filter)}
|
||||||
onBlur={trackCallback('select search filter')}
|
onBlur={trackCallback('select search filter', { filter })}
|
||||||
>
|
>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="personal">For you</option>
|
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
</select>
|
</select>
|
||||||
{!hideOrderSelector && (
|
{!hideOrderSelector && (
|
||||||
|
@ -174,7 +211,7 @@ export function ContractSearch(props: {
|
||||||
classNames={{
|
classNames={{
|
||||||
select: '!select !select-bordered',
|
select: '!select !select-bordered',
|
||||||
}}
|
}}
|
||||||
onBlur={trackCallback('select search sort')}
|
onBlur={trackCallback('select search sort', { sort })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Configure
|
<Configure
|
||||||
|
@ -187,11 +224,52 @@ export function ContractSearch(props: {
|
||||||
|
|
||||||
<Spacer h={3} />
|
<Spacer h={3} />
|
||||||
|
|
||||||
{/*<Spacer h={4} />*/}
|
{pillsEnabled && (
|
||||||
|
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||||
|
<PillButton
|
||||||
|
key={'all'}
|
||||||
|
selected={pillFilter === undefined}
|
||||||
|
onSelect={selectFilter(undefined)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</PillButton>
|
||||||
|
<PillButton
|
||||||
|
key={'personal'}
|
||||||
|
selected={pillFilter === 'personal'}
|
||||||
|
onSelect={selectFilter('personal')}
|
||||||
|
>
|
||||||
|
{user ? 'For you' : 'Featured'}
|
||||||
|
</PillButton>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<PillButton
|
||||||
|
key={'your-bets'}
|
||||||
|
selected={pillFilter === 'your-bets'}
|
||||||
|
onSelect={selectFilter('your-bets')}
|
||||||
|
>
|
||||||
|
Your bets
|
||||||
|
</PillButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pillGroups.map(({ name, slug }) => {
|
||||||
|
return (
|
||||||
|
<PillButton
|
||||||
|
key={slug}
|
||||||
|
selected={pillFilter === slug}
|
||||||
|
onSelect={selectFilter(slug)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</PillButton>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Spacer h={3} />
|
||||||
|
|
||||||
{filter === 'personal' &&
|
{filter === 'personal' &&
|
||||||
(follows ?? []).length === 0 &&
|
(follows ?? []).length === 0 &&
|
||||||
(memberGroupSlugs ?? []).length === 0 ? (
|
memberGroupSlugs.length === 0 ? (
|
||||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
<>You're not following anyone, nor in any of your own groups yet.</>
|
||||||
) : (
|
) : (
|
||||||
<ContractSearchInner
|
<ContractSearchInner
|
||||||
|
|
|
@ -2,16 +2,17 @@ import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
import { CATEGORY_LIST } from '../../../common/categories'
|
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
||||||
import { parseTags, exhibitExts } from 'common/util/parse'
|
import { exhibitExts, parseTags } from 'common/util/parse'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { updateContract } from 'web/lib/firebase/contracts'
|
import { updateContract } from 'web/lib/firebase/contracts'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { TagsList } from '../tags-list'
|
|
||||||
import { Content } from '../editor'
|
import { Content } from '../editor'
|
||||||
import { Editor } from '@tiptap/react'
|
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||||
|
import { Button } from '../button'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||||
|
|
||||||
export function ContractDescription(props: {
|
export function ContractDescription(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -19,20 +20,36 @@ export function ContractDescription(props: {
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, isCreator, className } = props
|
const { contract, isCreator, className } = props
|
||||||
const descriptionTimestamp = () => `${dayjs().format('MMM D, h:mma')}: `
|
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
|
return (
|
||||||
|
<div className={clsx('mt-2 text-gray-700', className)}>
|
||||||
|
{isCreator || isAdmin ? (
|
||||||
|
<RichEditContract contract={contract} isAdmin={isAdmin && !isCreator} />
|
||||||
|
) : (
|
||||||
|
<Content content={contract.description} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const desc = contract.description ?? ''
|
function editTimestamp() {
|
||||||
|
return `${dayjs().format('MMM D, h:mma')}: `
|
||||||
|
}
|
||||||
|
|
||||||
// Append the new description (after a newline)
|
function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
||||||
async function saveDescription(newText: string) {
|
const { contract, isAdmin } = props
|
||||||
const editor = new Editor({ content: desc, extensions: exhibitExts })
|
const [editing, setEditing] = useState(false)
|
||||||
editor
|
const [editingQ, setEditingQ] = useState(false)
|
||||||
.chain()
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
.focus('end')
|
|
||||||
.insertContent('<br /><br />')
|
const { editor, upload } = useTextEditor({
|
||||||
.insertContent(newText.trim())
|
max: MAX_DESCRIPTION_LENGTH,
|
||||||
.run()
|
defaultValue: contract.description,
|
||||||
|
disabled: isSubmitting,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveDescription() {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
const tags = parseTags(
|
const tags = parseTags(
|
||||||
`${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
|
`${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
|
||||||
|
@ -46,76 +63,94 @@ export function ContractDescription(props: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags } = contract
|
return editing ? (
|
||||||
const categories = tags.filter((tag) =>
|
<>
|
||||||
CATEGORY_LIST.includes(tag.toLowerCase())
|
<TextEditor editor={editor} upload={upload} />
|
||||||
)
|
<Spacer h={2} />
|
||||||
|
<Row className="gap-2">
|
||||||
return (
|
<Button
|
||||||
<div
|
onClick={async () => {
|
||||||
className={clsx(
|
setIsSubmitting(true)
|
||||||
'mt-2 whitespace-pre-line break-words text-gray-700',
|
await saveDescription()
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Content content={desc} />
|
|
||||||
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<TagsList tags={categories} noLabel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
{isCreator && (
|
|
||||||
<EditContract
|
|
||||||
// Note: Because descriptionTimestamp is called once, later edits use
|
|
||||||
// a stale timestamp. Ideally this is a function that gets called when
|
|
||||||
// isEditing is set to true.
|
|
||||||
text={descriptionTimestamp()}
|
|
||||||
onSave={saveDescription}
|
|
||||||
buttonText="Add to description"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<EditContract
|
|
||||||
text={contract.question}
|
|
||||||
onSave={(question) => updateContract(contract.id, { question })}
|
|
||||||
buttonText="ADMIN: Edit question"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* {isAdmin && (
|
|
||||||
<EditContract
|
|
||||||
text={contract.createdTime.toString()}
|
|
||||||
onSave={(time) =>
|
|
||||||
updateContract(contract.id, { createdTime: Number(time) })
|
|
||||||
}
|
|
||||||
buttonText="ADMIN: Edit createdTime"
|
|
||||||
/>
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditContract(props: {
|
|
||||||
text: string
|
|
||||||
onSave: (newText: string) => void
|
|
||||||
buttonText: string
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState(props.text)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const onSave = (newText: string) => {
|
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
setText(props.text) // Reset to original text
|
setIsSubmitting(false)
|
||||||
props.onSave(newText)
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" onClick={() => setEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Content content={contract.description} />
|
||||||
|
<Spacer h={2} />
|
||||||
|
<Row className="items-center gap-2">
|
||||||
|
{isAdmin && 'Admin: '}
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(true)
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.setContent(contract.description)
|
||||||
|
.focus('end')
|
||||||
|
.insertContent(`<p>${editTimestamp()}</p>`)
|
||||||
|
.run()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit description
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" size="xs" onClick={() => setEditingQ(true)}>
|
||||||
|
Edit question
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
<EditQuestion
|
||||||
|
contract={contract}
|
||||||
|
editing={editingQ}
|
||||||
|
setEditing={setEditingQ}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditQuestion(props: {
|
||||||
|
contract: Contract
|
||||||
|
editing: boolean
|
||||||
|
setEditing: (editing: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { contract, editing, setEditing } = props
|
||||||
|
const [text, setText] = useState(contract.question)
|
||||||
|
|
||||||
|
function questionChanged(oldQ: string, newQ: string) {
|
||||||
|
return `<p>${editTimestamp()}<s>${oldQ}</s> → ${newQ}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinContent(oldContent: ContentType, newContent: string) {
|
||||||
|
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||||
|
editor.chain().focus('end').insertContent(newContent).run()
|
||||||
|
return editor.getJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async (newText: string) => {
|
||||||
|
setEditing(false)
|
||||||
|
await updateContract(contract.id, {
|
||||||
|
question: newText,
|
||||||
|
description: joinContent(
|
||||||
|
contract.description,
|
||||||
|
questionChanged(contract.question, newText)
|
||||||
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return editing ? (
|
return editing ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Textarea
|
<Textarea
|
||||||
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
|
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
|
||||||
rows={3}
|
rows={2}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value || '')}
|
onChange={(e) => setText(e.target.value || '')}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
@ -130,28 +165,11 @@ function EditContract(props: {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Row className="gap-2">
|
<Row className="gap-2">
|
||||||
<button
|
<Button onClick={() => onSave(text)}>Save</Button>
|
||||||
className="btn btn-neutral btn-outline btn-sm"
|
<Button color="gray" onClick={() => setEditing(false)}>
|
||||||
onClick={() => onSave(text)}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-error btn-outline btn-sm"
|
|
||||||
onClick={() => setEditing(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null
|
||||||
<Row>
|
|
||||||
<button
|
|
||||||
className="btn btn-neutral btn-outline btn-xs mt-4"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
{props.buttonText}
|
|
||||||
</button>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { UserLink } from '../user-page'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractMetrics,
|
contractMetrics,
|
||||||
contractPool,
|
contractPath,
|
||||||
updateContract,
|
updateContract,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
@ -22,17 +22,19 @@ import { useState } from 'react'
|
||||||
import { ContractInfoDialog } from './contract-info-dialog'
|
import { ContractInfoDialog } from './contract-info-dialog'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
|
||||||
import { TagsList } from '../tags-list'
|
|
||||||
import { UserFollowButton } from '../follow-button'
|
import { UserFollowButton } from '../follow-button'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
|
||||||
import { SiteLink } from 'web/components/site-link'
|
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
import { exhibitExts } from 'common/util/parse'
|
||||||
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -46,15 +48,12 @@ export function MiscDetails(props: {
|
||||||
volume,
|
volume,
|
||||||
volume24Hours,
|
volume24Hours,
|
||||||
closeTime,
|
closeTime,
|
||||||
tags,
|
|
||||||
isResolved,
|
isResolved,
|
||||||
createdTime,
|
createdTime,
|
||||||
resolutionTime,
|
resolutionTime,
|
||||||
|
groupLinks,
|
||||||
} = contract
|
} = contract
|
||||||
// Show at most one category that this contract is tagged by
|
|
||||||
const categories = CATEGORY_LIST.filter((category) =>
|
|
||||||
tags.map((t) => t.toLowerCase()).includes(category)
|
|
||||||
).slice(0, 1)
|
|
||||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -76,13 +75,21 @@ export function MiscDetails(props: {
|
||||||
{fromNow(resolutionTime || 0)}
|
{fromNow(resolutionTime || 0)}
|
||||||
</Row>
|
</Row>
|
||||||
) : volume > 0 || !isNew ? (
|
) : volume > 0 || !isNew ? (
|
||||||
<Row>{contractPool(contract)} pool</Row>
|
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
||||||
) : (
|
) : (
|
||||||
<NewContractBadge />
|
<NewContractBadge />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{groupLinks && groupLinks.length > 0 && (
|
||||||
<TagsList className="text-gray-400" tags={categories} noLabel />
|
<SiteLink
|
||||||
|
href={groupPath(groupLinks[0].slug)}
|
||||||
|
className="text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
<Row className={'line-clamp-1 flex-wrap items-center '}>
|
||||||
|
<UserGroupIcon className="mx-1 mb-0.5 inline h-4 w-4 shrink-0" />
|
||||||
|
{groupLinks[0].name}
|
||||||
|
</Row>
|
||||||
|
</SiteLink>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
|
@ -130,34 +137,15 @@ export function ContractDetails(props: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, isCreator, disabled } = props
|
const { contract, bets, isCreator, disabled } = props
|
||||||
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } =
|
||||||
|
contract
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||||
|
|
||||||
const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => {
|
|
||||||
return g2.createdTime - g1.createdTime
|
|
||||||
})
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
const groupsUserIsMemberOf = groups
|
|
||||||
? groups.filter((g) => g.memberIds.includes(contract.creatorId))
|
|
||||||
: []
|
|
||||||
const groupsUserIsCreatorOf = groups
|
|
||||||
? groups.filter((g) => g.creatorId === contract.creatorId)
|
|
||||||
: []
|
|
||||||
|
|
||||||
// Priorities for which group the contract belongs to:
|
|
||||||
// In order of created most recently
|
|
||||||
// Group that the contract owner created
|
|
||||||
// Group the contract owner is a member of
|
|
||||||
// Any group the contract is in
|
|
||||||
const groupToDisplay =
|
const groupToDisplay =
|
||||||
groupsUserIsCreatorOf.length > 0
|
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
|
||||||
? groupsUserIsCreatorOf[0]
|
const user = useUser()
|
||||||
: groupsUserIsMemberOf.length > 0
|
const [open, setOpen] = useState(false)
|
||||||
? groupsUserIsMemberOf[0]
|
|
||||||
: groups
|
|
||||||
? groups[0]
|
|
||||||
: undefined
|
|
||||||
return (
|
return (
|
||||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
|
@ -178,16 +166,34 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||||
</Row>
|
</Row>
|
||||||
{groupToDisplay ? (
|
<Row>
|
||||||
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
|
<Button
|
||||||
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
|
size={'xs'}
|
||||||
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
|
className={'max-w-[200px]'}
|
||||||
<span>{groupToDisplay.name}</span>
|
color={'gray-white'}
|
||||||
</SiteLink>
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||||
|
<span className={'line-clamp-1'}>
|
||||||
|
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||||
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
</Button>
|
||||||
<div />
|
</Row>
|
||||||
)}
|
<Modal open={open} setOpen={setOpen} size={'md'}>
|
||||||
|
<Col
|
||||||
|
className={
|
||||||
|
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ContractGroupsList
|
||||||
|
groupLinks={groupLinks ?? []}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{(!!closeTime || !!resolvedDate) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
<Row className="items-center gap-1">
|
<Row className="items-center gap-1">
|
||||||
|
@ -222,9 +228,12 @@ export function ContractDetails(props: {
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
<ShareIconButton
|
<ShareIconButton
|
||||||
contract={contract}
|
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||||
|
user?.username && contract.creatorUsername !== user?.username
|
||||||
|
? '?referrer=' + user?.username
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||||
username={user?.username}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||||
|
@ -321,12 +330,13 @@ function EditableCloseDate(props: {
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
className="btn btn-xs btn-ghost"
|
size={'xs'}
|
||||||
|
color={'gray-white'}
|
||||||
onClick={() => setIsEditingCloseTime(true)}
|
onClick={() => setIsEditingCloseTime(true)}
|
||||||
>
|
>
|
||||||
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
|
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,7 +16,6 @@ import { ShareEmbedButton } from '../share-embed-button'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { TweetButton } from '../tweet-button'
|
import { TweetButton } from '../tweet-button'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
import { InfoTooltip } from '../info-tooltip'
|
||||||
import { TagsInput } from 'web/components/tags-input'
|
|
||||||
import { DuplicateContractButton } from '../copy-contract-button'
|
import { DuplicateContractButton } from '../copy-contract-button'
|
||||||
|
|
||||||
export const contractDetailsButtonClassName =
|
export const contractDetailsButtonClassName =
|
||||||
|
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div>Tags</div>
|
|
||||||
<TagsInput contract={contract} />
|
|
||||||
<div />
|
|
||||||
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
||||||
<LiquidityPanel contract={contract} />
|
<LiquidityPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
141
web/components/contract/contract-leaderboard.tsx
Normal file
141
web/components/contract/contract-leaderboard.tsx
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import { Bet } from 'common/bet'
|
||||||
|
import { Comment } from 'common/comment'
|
||||||
|
import { resolvedPayout } from 'common/calculate'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
import { useUserById } from 'web/hooks/use-user'
|
||||||
|
import { listUsers, User } from 'web/lib/firebase/users'
|
||||||
|
import { FeedBet } from '../feed/feed-bets'
|
||||||
|
import { FeedComment } from '../feed/feed-comments'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
import { Leaderboard } from '../leaderboard'
|
||||||
|
import { Title } from '../title'
|
||||||
|
|
||||||
|
export function ContractLeaderboard(props: {
|
||||||
|
contract: Contract
|
||||||
|
bets: Bet[]
|
||||||
|
}) {
|
||||||
|
const { contract, bets } = props
|
||||||
|
const [users, setUsers] = useState<User[]>()
|
||||||
|
|
||||||
|
const { userProfits, top5Ids } = useMemo(() => {
|
||||||
|
// Create a map of userIds to total profits (including sales)
|
||||||
|
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||||
|
const betsByUser = groupBy(openBets, 'userId')
|
||||||
|
|
||||||
|
const userProfits = mapValues(betsByUser, (bets) =>
|
||||||
|
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||||
|
)
|
||||||
|
// Find the 5 users with the most profits
|
||||||
|
const top5Ids = Object.entries(userProfits)
|
||||||
|
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
|
||||||
|
.filter(([, p]) => p > 0)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([id]) => id)
|
||||||
|
return { userProfits, top5Ids }
|
||||||
|
}, [contract, bets])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (top5Ids.length > 0) {
|
||||||
|
listUsers(top5Ids).then((users) => {
|
||||||
|
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||||
|
setUsers(sortedUsers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [userProfits, top5Ids])
|
||||||
|
|
||||||
|
return users && users.length > 0 ? (
|
||||||
|
<Leaderboard
|
||||||
|
title="🏅 Top traders"
|
||||||
|
users={users || []}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Total profit',
|
||||||
|
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="mt-12 max-w-sm"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractTopTrades(props: {
|
||||||
|
contract: Contract
|
||||||
|
bets: Bet[]
|
||||||
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
|
}) {
|
||||||
|
const { contract, bets, comments, tips } = props
|
||||||
|
const commentsById = keyBy(comments, 'id')
|
||||||
|
const betsById = keyBy(bets, 'id')
|
||||||
|
|
||||||
|
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||||
|
// Otherwise, we record the profit at resolution time
|
||||||
|
const profitById: Record<string, number> = {}
|
||||||
|
for (const bet of bets) {
|
||||||
|
if (bet.sale) {
|
||||||
|
const originalBet = betsById[bet.sale.betId]
|
||||||
|
const profit = bet.sale.amount - originalBet.amount
|
||||||
|
profitById[bet.id] = profit
|
||||||
|
profitById[originalBet.id] = profit
|
||||||
|
} else {
|
||||||
|
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now find the betId with the highest profit
|
||||||
|
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||||
|
const topBettor = useUserById(betsById[topBetId]?.userId)
|
||||||
|
|
||||||
|
// And also the commentId of the comment with the highest profit
|
||||||
|
const topCommentId = sortBy(
|
||||||
|
comments,
|
||||||
|
(c) => c.betId && -profitById[c.betId]
|
||||||
|
)[0]?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 max-w-sm">
|
||||||
|
{topCommentId && profitById[topCommentId] > 0 && (
|
||||||
|
<>
|
||||||
|
<Title text="💬 Proven correct" className="!mt-0" />
|
||||||
|
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||||
|
<FeedComment
|
||||||
|
contract={contract}
|
||||||
|
comment={commentsById[topCommentId]}
|
||||||
|
tips={tips[topCommentId]}
|
||||||
|
betsBySameUser={[betsById[topCommentId]]}
|
||||||
|
truncate={false}
|
||||||
|
smallAvatar={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
{commentsById[topCommentId].userName} made{' '}
|
||||||
|
{formatMoney(profitById[topCommentId] || 0)}!
|
||||||
|
</div>
|
||||||
|
<Spacer h={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* If they're the same, only show the comment; otherwise show both */}
|
||||||
|
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||||
|
<>
|
||||||
|
<Title text="💸 Smartest money" className="!mt-0" />
|
||||||
|
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||||
|
<FeedBet
|
||||||
|
contract={contract}
|
||||||
|
bet={betsById[topBetId]}
|
||||||
|
hideOutcome={false}
|
||||||
|
smallAvatar={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import { Bet } from 'common/bet'
|
||||||
import { getInitialProbability } from 'common/calculate'
|
import { getInitialProbability } from 'common/calculate'
|
||||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { getMappedValue } from 'common/pseudo-numeric'
|
|
||||||
import { formatLargeNumber } from 'common/util/format'
|
import { formatLargeNumber } from 'common/util/format'
|
||||||
|
|
||||||
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
|
@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
...bets.map((bet) => bet.createdTime),
|
...bets.map((bet) => bet.createdTime),
|
||||||
].map((time) => new Date(time))
|
].map((time) => new Date(time))
|
||||||
|
|
||||||
const f = getMappedValue(contract)
|
const f: (p: number) => number = isBinary
|
||||||
|
? (p) => p
|
||||||
|
: isLogScale
|
||||||
|
? (p) => p * Math.log10(contract.max - contract.min + 1)
|
||||||
|
: (p) => p * (contract.max - contract.min) + contract.min
|
||||||
|
|
||||||
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
|
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
|
||||||
|
|
||||||
|
@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
|
|
||||||
const points: { x: Date; y: number }[] = []
|
const points: { x: Date; y: number }[] = []
|
||||||
const s = isBinary ? 100 : 1
|
const s = isBinary ? 100 : 1
|
||||||
const c = isLogScale && contract.min === 0 ? 1 : 0
|
|
||||||
|
|
||||||
for (let i = 0; i < times.length - 1; i++) {
|
for (let i = 0; i < times.length - 1; i++) {
|
||||||
points[points.length] = { x: times[i], y: s * probs[i] + c }
|
points[points.length] = { x: times[i], y: s * probs[i] }
|
||||||
const numPoints: number = Math.floor(
|
const numPoints: number = Math.floor(
|
||||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
||||||
)
|
)
|
||||||
|
@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
x: dayjs(times[i])
|
x: dayjs(times[i])
|
||||||
.add(thisTimeStep * n, 'ms')
|
.add(thisTimeStep * n, 'ms')
|
||||||
.toDate(),
|
.toDate(),
|
||||||
y: s * probs[i] + c,
|
y: s * probs[i],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
|
|
||||||
const formatter = isBinary
|
const formatter = isBinary
|
||||||
? formatPercent
|
? formatPercent
|
||||||
|
: isLogScale
|
||||||
|
? (x: DatumValue) =>
|
||||||
|
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
|
||||||
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
yScale={
|
yScale={
|
||||||
isBinary
|
isBinary
|
||||||
? { min: 0, max: 100, type: 'linear' }
|
? { min: 0, max: 100, type: 'linear' }
|
||||||
: {
|
: isLogScale
|
||||||
min: contract.min + c,
|
? {
|
||||||
max: contract.max + c,
|
min: 0,
|
||||||
type: contract.isLogScale ? 'log' : 'linear',
|
max: Math.log10(contract.max - contract.min + 1),
|
||||||
|
type: 'linear',
|
||||||
}
|
}
|
||||||
|
: { min: contract.min, max: contract.max, type: 'linear' }
|
||||||
}
|
}
|
||||||
yFormat={formatter}
|
yFormat={formatter}
|
||||||
gridYValues={yTickValues}
|
gridYValues={yTickValues}
|
||||||
|
@ -143,6 +150,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
enableSlices="x"
|
enableSlices="x"
|
||||||
enableGridX={!!width && width >= 800}
|
enableGridX={!!width && width >= 800}
|
||||||
enableArea
|
enableArea
|
||||||
|
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
|
||||||
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
||||||
animate={false}
|
animate={false}
|
||||||
sliceTooltip={SliceTooltip}
|
sliceTooltip={SliceTooltip}
|
||||||
|
|
|
@ -3,31 +3,35 @@ import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
|
||||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { Row } from './layout/row'
|
||||||
function copyContractUrl(contract: Contract) {
|
|
||||||
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyLinkButton(props: {
|
export function CopyLinkButton(props: {
|
||||||
contract: Contract
|
url: string
|
||||||
|
displayUrl?: string
|
||||||
|
tracking?: string
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
toastClassName?: string
|
toastClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, buttonClassName, toastClassName } = props
|
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Row className="w-full">
|
||||||
|
<input
|
||||||
|
className="input input-bordered flex-1 rounded-r-none text-gray-500"
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
value={displayUrl ?? url}
|
||||||
|
/>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
as="div"
|
as="div"
|
||||||
className="relative z-10 flex-shrink-0"
|
className="relative z-10 flex-shrink-0"
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
copyContractUrl(contract)
|
copyToClipboard(url)
|
||||||
track('copy share link')
|
track(tracking ?? 'copy share link')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
|
@ -56,5 +60,6 @@ export function CopyLinkButton(props: {
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { firebaseLogin, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, User } from 'web/lib/firebase/users'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const createButtonStyle =
|
export const createButtonStyle =
|
||||||
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
|
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
|
||||||
|
|
||||||
export const CreateQuestionButton = (props: {
|
export const CreateQuestionButton = (props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
overrideText?: string
|
overrideText?: string
|
||||||
|
@ -15,17 +17,23 @@ export const CreateQuestionButton = (props: {
|
||||||
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
||||||
|
|
||||||
const { user, overrideText, className, query } = props
|
const { user, overrideText, className, query } = props
|
||||||
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex justify-center', className)}>
|
<div className={clsx('flex justify-center', className)}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href={`/create${query ? query : ''}`} passHref>
|
<Link href={`/create${query ? query : ''}`} passHref>
|
||||||
<button className={clsx(gradient, createButtonStyle)}>
|
<button className={clsx(gradient, createButtonStyle)}>
|
||||||
{overrideText ? overrideText : 'Create a question'}
|
{overrideText ? overrideText : 'Create a market'}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={firebaseLogin}
|
onClick={async () => {
|
||||||
|
// login, and then reload the page, to hit any SSR redirect (e.g.
|
||||||
|
// redirecting from / to /home for logged in users)
|
||||||
|
await firebaseLogin()
|
||||||
|
router.replace(router.asPath)
|
||||||
|
}}
|
||||||
className={clsx(gradient, createButtonStyle)}
|
className={clsx(gradient, createButtonStyle)}
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
|
|
|
@ -12,16 +12,24 @@ import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
import { exhibitExts } from 'common/util/parse'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
|
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { Spacer } from './layout/spacer'
|
||||||
|
|
||||||
const proseClass = clsx(
|
const proseClass = clsx(
|
||||||
'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none'
|
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||||
|
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||||
)
|
)
|
||||||
|
|
||||||
export function useTextEditor(props: {
|
export function useTextEditor(props: {
|
||||||
|
@ -34,7 +42,7 @@ export function useTextEditor(props: {
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
'box-content min-h-[6em] textarea textarea-bordered'
|
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
@ -55,6 +63,7 @@ export function useTextEditor(props: {
|
||||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Iframe,
|
||||||
],
|
],
|
||||||
content: defaultValue,
|
content: defaultValue,
|
||||||
})
|
})
|
||||||
|
@ -68,12 +77,19 @@ export function useTextEditor(props: {
|
||||||
(file) => file.type.startsWith('image')
|
(file) => file.type.startsWith('image')
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!imageFiles.length) {
|
if (imageFiles.length) {
|
||||||
return // if no files pasted, use default paste handler
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
upload.mutate(imageFiles)
|
upload.mutate(imageFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the pasted content is iframe code, directly inject it
|
||||||
|
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
||||||
|
if (isValidIframe(text)) {
|
||||||
|
editor.chain().insertContent(text).run()
|
||||||
|
return true // Prevent the code from getting pasted as text
|
||||||
|
}
|
||||||
|
|
||||||
|
return // Otherwise, use default paste handler
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -85,31 +101,76 @@ export function useTextEditor(props: {
|
||||||
return { editor, upload }
|
return { editor, upload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidIframe(text: string) {
|
||||||
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
}) {
|
}) {
|
||||||
const { editor, upload } = props
|
const { editor, upload } = props
|
||||||
|
const [iframeOpen, setIframeOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
{editor && (
|
{editor && (
|
||||||
<FloatingMenu
|
<FloatingMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className="w-full text-sm text-slate-300"
|
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
||||||
>
|
>
|
||||||
Type <em>*anything*</em> or even paste or{' '}
|
Type <em>*markdown*</em>. Paste or{' '}
|
||||||
<FileUploadButton
|
<FileUploadButton
|
||||||
className="link text-blue-300"
|
className="link text-blue-300"
|
||||||
onFiles={upload.mutate}
|
onFiles={upload.mutate}
|
||||||
>
|
>
|
||||||
upload an image
|
upload
|
||||||
</FileUploadButton>
|
</FileUploadButton>{' '}
|
||||||
|
images!
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
{/* Spacer element to match the height of the toolbar */}
|
||||||
|
<div className="py-2" aria-hidden="true">
|
||||||
|
{/* Matches height of button in toolbar (1px border + 36px content height) */}
|
||||||
|
<div className="py-px">
|
||||||
|
<div className="h-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar, with buttons for image and embeds */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileUploadButton
|
||||||
|
onFiles={upload.mutate}
|
||||||
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Upload an image</span>
|
||||||
|
</FileUploadButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIframeOpen(true)}
|
||||||
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<IframeModal
|
||||||
|
editor={editor}
|
||||||
|
open={iframeOpen}
|
||||||
|
setOpen={setIframeOpen}
|
||||||
|
/>
|
||||||
|
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Embed an iframe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
||||||
{upload.isError && (
|
{upload.isError && (
|
||||||
|
@ -119,9 +180,69 @@ export function TextEditor(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IframeModal(props: {
|
||||||
|
editor: Editor | null
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { editor, open, setOpen } = props
|
||||||
|
const [embedCode, setEmbedCode] = useState('')
|
||||||
|
const valid = isValidIframe(embedCode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
<Col className="gap-2 rounded bg-white p-6">
|
||||||
|
<label
|
||||||
|
htmlFor="embed"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Embed a market, Youtube video, etc.
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="embed"
|
||||||
|
id="embed"
|
||||||
|
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder='e.g. <iframe src="..."></iframe>'
|
||||||
|
value={embedCode}
|
||||||
|
onChange={(e) => setEmbedCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview the embed if it's valid */}
|
||||||
|
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
|
||||||
|
|
||||||
|
<Row className="gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() => {
|
||||||
|
if (editor && valid) {
|
||||||
|
editor.chain().insertContent(embedCode).run()
|
||||||
|
setEmbedCode('')
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Embed
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setEmbedCode('')
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const useUploadMutation = (editor: Editor | null) =>
|
const useUploadMutation = (editor: Editor | null) =>
|
||||||
useMutation(
|
useMutation(
|
||||||
(files: File[]) =>
|
(files: File[]) =>
|
||||||
|
// TODO: Images should be uploaded under a particular username
|
||||||
Promise.all(files.map((file) => uploadImage('default', file))),
|
Promise.all(files.map((file) => uploadImage('default', file))),
|
||||||
{
|
{
|
||||||
onSuccess(urls) {
|
onSuccess(urls) {
|
||||||
|
@ -136,7 +257,7 @@ const useUploadMutation = (editor: Editor | null) =>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function RichContent(props: { content: JSONContent }) {
|
function RichContent(props: { content: JSONContent | string }) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: { attributes: { class: proseClass } },
|
editorProps: { attributes: { class: proseClass } },
|
||||||
|
@ -153,7 +274,9 @@ function RichContent(props: { content: JSONContent }) {
|
||||||
export function Content(props: { content: JSONContent | string }) {
|
export function Content(props: { content: JSONContent | string }) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
return typeof content === 'string' ? (
|
return typeof content === 'string' ? (
|
||||||
|
<div className="whitespace-pre-line font-light leading-relaxed">
|
||||||
<Linkify text={content} />
|
<Linkify text={content} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RichContent content={content} />
|
<RichContent content={content} />
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { formatPercent } from 'common/util/format'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
|
||||||
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
|
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
|
||||||
import {
|
import {
|
||||||
CommentInput,
|
CommentInput,
|
||||||
CommentRepliesList,
|
CommentRepliesList,
|
||||||
|
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
|
||||||
import { groupBy } from 'lodash'
|
import { groupBy } from 'lodash'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
import { useEvent } from 'web/hooks/use-event'
|
||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function FeedAnswerCommentGroup(props: {
|
export function FeedAnswerCommentGroup(props: {
|
||||||
|
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, name, text } = answer
|
||||||
|
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUsername, setReplyToUsername] = useState('')
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [showReply, setShowReply] = useState(false)
|
const [showReply, setShowReply] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||||
const [highlighted, setHighlighted] = useState(false)
|
const [highlighted, setHighlighted] = useState(false)
|
||||||
|
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
const commentsList = comments.filter(
|
const commentsList = comments.filter(
|
||||||
(comment) => comment.answerOutcome === answer.number.toString()
|
(comment) => comment.answerOutcome === answer.number.toString()
|
||||||
)
|
)
|
||||||
const thisAnswerProb = getDpmOutcomeProbability(
|
|
||||||
contract.totalShares,
|
|
||||||
answer.id
|
|
||||||
)
|
|
||||||
const probPercent = formatPercent(thisAnswerProb)
|
|
||||||
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
|
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
|
||||||
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
|
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
|
||||||
const isFreeResponseContractPage = !!commentsByCurrentUser
|
const isFreeResponseContractPage = !!commentsByCurrentUser
|
||||||
|
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
}, [answerElementId, router.asPath])
|
}, [answerElementId, router.asPath])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}>
|
<Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
|
||||||
<Modal open={open} setOpen={setOpen}>
|
|
||||||
<AnswerBetPanel
|
|
||||||
answer={answer}
|
|
||||||
contract={contract}
|
|
||||||
closePanel={() => setOpen(false)}
|
|
||||||
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
|
|
||||||
isModal={true}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'my-4 flex gap-3 space-x-3 transition-all duration-1000',
|
'flex gap-3 space-x-3 pt-4 transition-all duration-1000',
|
||||||
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
|
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
|
||||||
)}
|
)}
|
||||||
id={answerElementId}
|
id={answerElementId}
|
||||||
>
|
>
|
||||||
<div className="px-1">
|
|
||||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||||
</div>
|
|
||||||
<Col className="min-w-0 flex-1 lg:gap-1">
|
<Col className="min-w-0 flex-1 lg:gap-1">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<UserLink username={username} name={name} /> answered
|
<UserLink username={username} name={name} /> answered
|
||||||
|
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Col className="align-items justify-between gap-4 sm:flex-row">
|
<Col className="align-items justify-between gap-2 sm:flex-row">
|
||||||
<span className="whitespace-pre-line text-lg">
|
<span className="whitespace-pre-line text-lg">
|
||||||
<Linkify text={text} />
|
<Linkify text={text} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Row className="items-center justify-center gap-4">
|
|
||||||
{isFreeResponseContractPage && (
|
{isFreeResponseContractPage && (
|
||||||
<div className={'sm:hidden'}>
|
<div className={'sm:hidden'}>
|
||||||
<button
|
<button
|
||||||
className={
|
className={'text-xs font-bold text-gray-500 hover:underline'}
|
||||||
'text-xs font-bold text-gray-500 hover:underline'
|
|
||||||
}
|
|
||||||
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
|
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={'align-items flex w-full justify-end gap-4 '}>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'text-2xl',
|
|
||||||
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{probPercent}
|
|
||||||
</span>
|
|
||||||
<BuyButton
|
|
||||||
className={clsx(
|
|
||||||
'btn-sm flex-initial !px-6 sm:flex',
|
|
||||||
tradingAllowed(contract) ? '' : '!hidden'
|
|
||||||
)}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
</Col>
|
||||||
{isFreeResponseContractPage && (
|
{isFreeResponseContractPage && (
|
||||||
<div className={'justify-initial hidden sm:block'}>
|
<div className={'justify-initial hidden sm:block'}>
|
||||||
|
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showReply && (
|
{showReply && (
|
||||||
<div className={'ml-6 pt-4'}>
|
<div className={'ml-6'}>
|
||||||
<span
|
<span
|
||||||
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CommentInput
|
<CommentInput
|
||||||
|
|
|
@ -93,6 +93,24 @@ export function BetStatusText(props: {
|
||||||
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
|
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
|
||||||
false
|
false
|
||||||
|
|
||||||
|
const fromProb =
|
||||||
|
hadPoolMatch || isFreeResponse
|
||||||
|
? isPseudoNumeric
|
||||||
|
? formatNumericProbability(bet.probBefore, contract)
|
||||||
|
: formatPercent(bet.probBefore)
|
||||||
|
: isPseudoNumeric
|
||||||
|
? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract)
|
||||||
|
: formatPercent(bet.limitProb ?? bet.probBefore)
|
||||||
|
|
||||||
|
const toProb =
|
||||||
|
hadPoolMatch || isFreeResponse
|
||||||
|
? isPseudoNumeric
|
||||||
|
? formatNumericProbability(bet.probAfter, contract)
|
||||||
|
: formatPercent(bet.probAfter)
|
||||||
|
: isPseudoNumeric
|
||||||
|
? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract)
|
||||||
|
: formatPercent(bet.limitProb ?? bet.probAfter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
|
@ -112,14 +130,9 @@ export function BetStatusText(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
truncate="short"
|
truncate="short"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
{isPseudoNumeric
|
{fromProb === toProb
|
||||||
? ' than ' + formatNumericProbability(bet.probAfter, contract)
|
? `at ${fromProb}`
|
||||||
: ' at ' +
|
: `from ${fromProb} to ${toProb}`}
|
||||||
formatPercent(
|
|
||||||
hadPoolMatch || isFreeResponse
|
|
||||||
? bet.probAfter
|
|
||||||
: bet.limitProb ?? bet.probAfter
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
|
|
|
@ -70,7 +70,7 @@ export function FeedCommentThread(props: {
|
||||||
if (showReply && inputRef) inputRef.focus()
|
if (showReply && inputRef) inputRef.focus()
|
||||||
}, [inputRef, showReply])
|
}, [inputRef, showReply])
|
||||||
return (
|
return (
|
||||||
<div className={'w-full flex-col pr-1'}>
|
<Col className={'w-full gap-3 pr-1'}>
|
||||||
<span
|
<span
|
||||||
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -86,7 +86,7 @@ export function FeedCommentThread(props: {
|
||||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||||
/>
|
/>
|
||||||
{showReply && (
|
{showReply && (
|
||||||
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
|
<Col className={'-pb-2 ml-6'}>
|
||||||
<span
|
<span
|
||||||
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -106,9 +106,9 @@ export function FeedCommentThread(props: {
|
||||||
setReplyToUsername('')
|
setReplyToUsername('')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Col>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,7 +142,7 @@ export function CommentRepliesList(props: {
|
||||||
id={comment.id}
|
id={comment.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative',
|
'relative',
|
||||||
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6'
|
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*draw a gray line from the comment to the left:*/}
|
{/*draw a gray line from the comment to the left:*/}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import BetRow from '../bet-row'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { ActivityItem } from './activity-items'
|
import { ActivityItem } from './activity-items'
|
||||||
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
|
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { trackClick } from 'web/lib/firebase/tracking'
|
import { trackClick } from 'web/lib/firebase/tracking'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
|
@ -118,6 +119,7 @@ export function FeedQuestion(props: {
|
||||||
const { volumeLabel } = contractMetrics(contract)
|
const { volumeLabel } = contractMetrics(contract)
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex gap-2'}>
|
<div className={'flex gap-2'}>
|
||||||
|
@ -149,7 +151,7 @@ export function FeedQuestion(props: {
|
||||||
href={
|
href={
|
||||||
props.contractPath ? props.contractPath : contractPath(contract)
|
props.contractPath ? props.contractPath : contractPath(contract)
|
||||||
}
|
}
|
||||||
onClick={() => trackClick(contract.id)}
|
onClick={() => user && trackClick(user.id, contract.id)}
|
||||||
className="text-lg text-indigo-700 sm:text-xl"
|
className="text-lg text-indigo-700 sm:text-xl"
|
||||||
>
|
>
|
||||||
{question}
|
{question}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Menu, Transition } from '@headlessui/react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
|
||||||
export function FilterSelectUsers(props: {
|
export function FilterSelectUsers(props: {
|
||||||
setSelectedUsers: (users: User[]) => void
|
setSelectedUsers: (users: User[]) => void
|
||||||
|
@ -35,8 +36,7 @@ export function FilterSelectUsers(props: {
|
||||||
return (
|
return (
|
||||||
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
||||||
!ignoreUserIds.includes(user.id) &&
|
!ignoreUserIds.includes(user.id) &&
|
||||||
(user.name.toLowerCase().includes(query.toLowerCase()) ||
|
searchInAny(query, user.name, user.username)
|
||||||
user.username.toLowerCase().includes(query.toLowerCase()))
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
73
web/components/groups/contract-groups-list.tsx
Normal file
73
web/components/groups/contract-groups-list.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { GroupLinkItem } from 'web/pages/groups'
|
||||||
|
import { XIcon } from '@heroicons/react/outline'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
|
import {
|
||||||
|
addContractToGroup,
|
||||||
|
removeContractFromGroup,
|
||||||
|
} from 'web/lib/firebase/groups'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import { GroupLink } from 'common/group'
|
||||||
|
import { useGroupsWithContract } from 'web/hooks/use-group'
|
||||||
|
|
||||||
|
export function ContractGroupsList(props: {
|
||||||
|
groupLinks: GroupLink[]
|
||||||
|
contract: Contract
|
||||||
|
user: User | null | undefined
|
||||||
|
}) {
|
||||||
|
const { groupLinks, user, contract } = props
|
||||||
|
const groups = useGroupsWithContract(contract)
|
||||||
|
return (
|
||||||
|
<Col className={'gap-2'}>
|
||||||
|
<span className={'text-xl text-indigo-700'}>
|
||||||
|
<SiteLink href={'/groups/'}>Groups</SiteLink>
|
||||||
|
</span>
|
||||||
|
{user && (
|
||||||
|
<Col className={'ml-2 items-center justify-between sm:flex-row'}>
|
||||||
|
<span>Add to: </span>
|
||||||
|
<GroupSelector
|
||||||
|
options={{
|
||||||
|
showSelector: true,
|
||||||
|
showLabel: false,
|
||||||
|
ignoreGroupIds: groupLinks.map((g) => g.groupId),
|
||||||
|
}}
|
||||||
|
setSelectedGroup={(group) =>
|
||||||
|
group && addContractToGroup(group, contract, user.id)
|
||||||
|
}
|
||||||
|
selectedGroup={undefined}
|
||||||
|
creator={user}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<Col className="ml-2 h-full justify-center text-gray-500">
|
||||||
|
No groups yet...
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Row
|
||||||
|
key={group.id}
|
||||||
|
className={clsx('items-center justify-between gap-2 p-2')}
|
||||||
|
>
|
||||||
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
|
<GroupLinkItem group={group} />
|
||||||
|
</Row>
|
||||||
|
{user && group.memberIds.includes(user.id) && (
|
||||||
|
<Button
|
||||||
|
color={'gray-white'}
|
||||||
|
size={'xs'}
|
||||||
|
onClick={() => removeContractFromGroup(group, contract)}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4 text-gray-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
|
@ -11,25 +11,28 @@ import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
|
||||||
export function GroupSelector(props: {
|
export function GroupSelector(props: {
|
||||||
selectedGroup?: Group
|
selectedGroup: Group | undefined
|
||||||
setSelectedGroup: (group: Group) => void
|
setSelectedGroup: (group: Group) => void
|
||||||
creator: User | null | undefined
|
creator: User | null | undefined
|
||||||
showSelector?: boolean
|
options: {
|
||||||
|
showSelector: boolean
|
||||||
|
showLabel: boolean
|
||||||
|
ignoreGroupIds?: string[]
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
const { selectedGroup, setSelectedGroup, creator, showSelector } = props
|
const { selectedGroup, setSelectedGroup, creator, options } = props
|
||||||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||||
|
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const memberGroups = useMemberGroups(creator?.id)
|
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
|
||||||
const filteredGroups = memberGroups
|
(group) => !ignoreGroupIds?.includes(group.id)
|
||||||
? query === ''
|
)
|
||||||
? memberGroups
|
const filteredGroups = memberGroups.filter((group) =>
|
||||||
: memberGroups.filter((group) => {
|
searchInAny(query, group.name)
|
||||||
return group.name.toLowerCase().includes(query.toLowerCase())
|
)
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
if (!showSelector || !creator) {
|
if (!showSelector || !creator) {
|
||||||
return (
|
return (
|
||||||
|
@ -56,19 +59,20 @@ export function GroupSelector(props: {
|
||||||
nullable={true}
|
nullable={true}
|
||||||
className={'text-sm'}
|
className={'text-sm'}
|
||||||
>
|
>
|
||||||
{({ open }) => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
{!open && setQuery('')}
|
{showLabel && (
|
||||||
<Combobox.Label className="label justify-start gap-2 text-base">
|
<Combobox.Label className="label justify-start gap-2 text-base">
|
||||||
Add to Group
|
Add to Group
|
||||||
<InfoTooltip text="Question will be displayed alongside the other questions in the group." />
|
<InfoTooltip text="Question will be displayed alongside the other questions in the group." />
|
||||||
</Combobox.Label>
|
</Combobox.Label>
|
||||||
|
)}
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<Combobox.Input
|
<Combobox.Input
|
||||||
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 "
|
className="w-60 rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 "
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
displayValue={(group: Group) => group && group.name}
|
displayValue={(group: Group) => group && group.name}
|
||||||
placeholder={'None'}
|
placeholder={'E.g. Science, Politics'}
|
||||||
/>
|
/>
|
||||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
|
||||||
<SelectorIcon
|
<SelectorIcon
|
||||||
|
|
|
@ -11,13 +11,15 @@ import { Modal } from 'web/components/layout/modal'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { GroupLink } from 'web/pages/groups'
|
import { GroupLinkItem } from 'web/pages/groups'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
export function GroupsButton(props: { user: User }) {
|
export function GroupsButton(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const groups = useMemberGroups(user.id)
|
const groups = useMemberGroups(user.id, undefined, {
|
||||||
|
by: 'mostRecentChatActivityTime',
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -75,7 +77,7 @@ function GroupItem(props: { group: Group; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||||
<Row className="line-clamp-1 items-center gap-2">
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
<GroupLink group={group} />
|
<GroupLinkItem group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
<JoinOrLeaveGroupButton group={group} />
|
<JoinOrLeaveGroupButton group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -133,7 +135,7 @@ export function JoinOrLeaveGroupButton(props: {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'btn btn-outline btn-sm',
|
'btn btn-outline btn-xs',
|
||||||
small && smallStyle,
|
small && smallStyle,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
30
web/components/info-box.tsx
Normal file
30
web/components/info-box.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { InformationCircleIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
|
import { Linkify } from './linkify'
|
||||||
|
|
||||||
|
export function InfoBox(props: {
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { title, text, className } = props
|
||||||
|
return (
|
||||||
|
<div className={clsx('rounded-md bg-gray-50 p-4', className)}>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<InformationCircleIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-black">{title}</h3>
|
||||||
|
<div className="mt-2 text-sm text-black">
|
||||||
|
<Linkify text={text} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -7,9 +7,17 @@ export function Modal(props: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { children, open, setOpen, className } = props
|
const { children, open, setOpen, size = 'md', className } = props
|
||||||
|
|
||||||
|
const sizeClass = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-5xl',
|
||||||
|
}[size]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={open} as={Fragment}>
|
<Transition.Root show={open} as={Fragment}>
|
||||||
|
@ -49,7 +57,8 @@ export function Modal(props: {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle',
|
'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:align-middle',
|
||||||
|
sizeClass,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from 'common/bet'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||||
|
@ -8,10 +7,14 @@ import { useState } from 'react'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||||
import { cancelBet } from 'web/lib/firebase/api'
|
import { cancelBet } from 'web/lib/firebase/api'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
|
import { Button } from './button'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Tabs } from './layout/tabs'
|
import { Modal } from './layout/modal'
|
||||||
|
import { Row } from './layout/row'
|
||||||
import { LoadingIndicator } from './loading-indicator'
|
import { LoadingIndicator } from './loading-indicator'
|
||||||
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
|
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
|
||||||
|
import { Subtitle } from './subtitle'
|
||||||
|
import { Title } from './title'
|
||||||
|
|
||||||
export function LimitBets(props: {
|
export function LimitBets(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -28,40 +31,36 @@ export function LimitBets(props: {
|
||||||
const yourBets = sortedBets.filter((bet) => bet.userId === user?.id)
|
const yourBets = sortedBets.filter((bet) => bet.userId === user?.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col className={className}>
|
||||||
className={clsx(
|
{yourBets.length === 0 && (
|
||||||
className,
|
<OrderBookButton
|
||||||
'gap-2 overflow-hidden rounded bg-white px-4 py-3'
|
className="self-end"
|
||||||
|
limitBets={sortedBets}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{yourBets.length > 0 && (
|
||||||
|
<Col
|
||||||
|
className={'mt-4 gap-2 overflow-hidden rounded bg-white px-4 py-3'}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Row className="mt-2 mb-4 items-center justify-between">
|
||||||
tabs={[
|
<Subtitle className="!mt-0 !mb-0" text="Your orders" />
|
||||||
...(yourBets.length > 0
|
|
||||||
? [
|
<OrderBookButton
|
||||||
{
|
className="self-end"
|
||||||
title: 'Your limit orders',
|
limitBets={sortedBets}
|
||||||
content: (
|
contract={contract}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<LimitOrderTable
|
<LimitOrderTable
|
||||||
limitBets={yourBets}
|
limitBets={yourBets}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isYou={true}
|
isYou={true}
|
||||||
/>
|
/>
|
||||||
),
|
</Col>
|
||||||
},
|
)}
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
title: 'All limit orders',
|
|
||||||
content: (
|
|
||||||
<LimitOrderTable
|
|
||||||
limitBets={sortedBets}
|
|
||||||
contract={contract}
|
|
||||||
isYou={false}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -77,11 +76,13 @@ export function LimitOrderTable(props: {
|
||||||
return (
|
return (
|
||||||
<table className="table-compact table w-full rounded text-gray-500">
|
<table className="table-compact table w-full rounded text-gray-500">
|
||||||
<thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
{!isYou && <th></th>}
|
{!isYou && <th></th>}
|
||||||
<th>Outcome</th>
|
<th>Outcome</th>
|
||||||
<th>Amount</th>
|
|
||||||
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
|
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
|
||||||
|
<th>Amount</th>
|
||||||
{isYou && <th></th>}
|
{isYou && <th></th>}
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{limitBets.map((bet) => (
|
{limitBets.map((bet) => (
|
||||||
|
@ -130,12 +131,12 @@ function LimitBet(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatMoney(orderAmount - amount)}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{isPseudoNumeric
|
{isPseudoNumeric
|
||||||
? getFormattedMappedValue(contract)(limitProb)
|
? getFormattedMappedValue(contract)(limitProb)
|
||||||
: formatPercent(limitProb)}
|
: formatPercent(limitProb)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatMoney(orderAmount - amount)}</td>
|
||||||
{isYou && (
|
{isYou && (
|
||||||
<td>
|
<td>
|
||||||
{isCancelling ? (
|
{isCancelling ? (
|
||||||
|
@ -153,3 +154,53 @@ function LimitBet(props: {
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OrderBookButton(props: {
|
||||||
|
limitBets: LimitBet[]
|
||||||
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { limitBets, contract, className } = props
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const yesBets = limitBets.filter((bet) => bet.outcome === 'YES')
|
||||||
|
const noBets = limitBets.filter((bet) => bet.outcome === 'NO').reverse()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
size="xs"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
Order book
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal open={open} setOpen={setOpen} size="lg">
|
||||||
|
<Col className="rounded bg-white p-4 py-6">
|
||||||
|
<Title className="!mt-0" text="Order book" />
|
||||||
|
<Row className="hidden items-start justify-start gap-2 md:flex">
|
||||||
|
<LimitOrderTable
|
||||||
|
limitBets={yesBets}
|
||||||
|
contract={contract}
|
||||||
|
isYou={false}
|
||||||
|
/>
|
||||||
|
<LimitOrderTable
|
||||||
|
limitBets={noBets}
|
||||||
|
contract={contract}
|
||||||
|
isYou={false}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Col className="md:hidden">
|
||||||
|
<LimitOrderTable
|
||||||
|
limitBets={limitBets}
|
||||||
|
contract={contract}
|
||||||
|
isYou={false}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,13 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import { fromNow } from 'web/lib/util/time'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Claim, Manalink } from 'common/manalink'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ShareIconButton } from './share-icon-button'
|
||||||
|
import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
||||||
|
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
|
||||||
|
import { useUserById } from 'web/hooks/use-user'
|
||||||
|
import getManalinkUrl from 'web/get-manalink-url'
|
||||||
export type ManalinkInfo = {
|
export type ManalinkInfo = {
|
||||||
expiresTime: number | null
|
expiresTime: number | null
|
||||||
maxUses: number | null
|
maxUses: number | null
|
||||||
|
@ -13,19 +19,19 @@ export type ManalinkInfo = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManalinkCard(props: {
|
export function ManalinkCard(props: {
|
||||||
className?: string
|
|
||||||
info: ManalinkInfo
|
info: ManalinkInfo
|
||||||
defaultMessage: string
|
className?: string
|
||||||
isClaiming: boolean
|
preview?: boolean
|
||||||
onClaim?: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const { className, defaultMessage, isClaiming, info, onClaim } = props
|
const { className, info, preview = false } = props
|
||||||
const { expiresTime, maxUses, uses, amount, message } = info
|
const { expiresTime, maxUses, uses, amount, message } = info
|
||||||
return (
|
return (
|
||||||
<div
|
<Col>
|
||||||
|
<Col
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
|
'min-h-20 group rounded-lg bg-gradient-to-br drop-shadow-sm transition-all',
|
||||||
|
getManalinkGradient(info.amount)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
|
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
|
||||||
|
@ -42,50 +48,63 @@ export function ManalinkCard(props: {
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
className="mb-6 block self-center transition-all group-hover:rotate-12"
|
className={clsx(
|
||||||
|
'block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12',
|
||||||
|
preview ? 'my-2' : 'w-1/2 md:mb-6 md:h-1/2'
|
||||||
|
)}
|
||||||
src="/logo-white.svg"
|
src="/logo-white.svg"
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
/>
|
/>
|
||||||
<Row className="justify-end rounded-b-xl bg-white p-4">
|
<Row className="rounded-b-lg bg-white p-4">
|
||||||
<Col>
|
<div
|
||||||
<div className="mb-1 text-xl text-indigo-500">
|
className={clsx(
|
||||||
|
'mb-1 text-xl text-indigo-500',
|
||||||
|
getManalinkAmountColor(amount)
|
||||||
|
)}
|
||||||
|
>
|
||||||
{formatMoney(amount)}
|
{formatMoney(amount)}
|
||||||
</div>
|
</div>
|
||||||
<div>{message || defaultMessage}</div>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<div className="ml-auto">
|
|
||||||
<button
|
|
||||||
className={clsx('btn', isClaiming ? 'loading disabled' : '')}
|
|
||||||
onClick={onClaim}
|
|
||||||
>
|
|
||||||
{isClaiming ? '' : 'Claim'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</Col>
|
||||||
|
<div className="text-md mt-2 mb-4 text-gray-500">{message}</div>
|
||||||
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManalinkCardPreview(props: {
|
export function ManalinkCardFromView(props: {
|
||||||
className?: string
|
className?: string
|
||||||
info: ManalinkInfo
|
link: Manalink
|
||||||
defaultMessage: string
|
highlightedSlug: string
|
||||||
}) {
|
}) {
|
||||||
const { className, defaultMessage, info } = props
|
const { className, link, highlightedSlug } = props
|
||||||
const { expiresTime, maxUses, uses, amount, message } = info
|
const { message, amount, expiresTime, maxUses, claims } = link
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Col>
|
||||||
|
<Col
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
|
||||||
className,
|
className,
|
||||||
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
|
link.slug === highlightedSlug ? 'shadow-md shadow-indigo-400' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Col
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-t-lg bg-gradient-to-br transition-all',
|
||||||
|
getManalinkGradient(link.amount)
|
||||||
|
)}
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
>
|
||||||
|
{showDetails && (
|
||||||
|
<ClaimsList
|
||||||
|
className="absolute h-full w-full bg-white opacity-90"
|
||||||
|
link={link}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
|
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
|
||||||
<div>
|
<div>
|
||||||
{maxUses != null
|
{maxUses != null
|
||||||
? `${maxUses - uses}/${maxUses} uses left`
|
? `${maxUses - claims.length}/${maxUses} uses left`
|
||||||
: `Unlimited use`}
|
: `Unlimited use`}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -94,17 +113,108 @@ export function ManalinkCardPreview(props: {
|
||||||
: 'Never expires'}
|
: 'Never expires'}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12"
|
className={clsx('my-auto block w-1/3 select-none self-center py-3')}
|
||||||
src="/logo-white.svg"
|
src="/logo-white.svg"
|
||||||
/>
|
/>
|
||||||
<Row className="rounded-b-lg bg-white p-2">
|
|
||||||
<Col className="text-md">
|
|
||||||
<div className="mb-1 text-indigo-500">{formatMoney(amount)}</div>
|
|
||||||
<div className="text-xs">{message || defaultMessage}</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
<Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'my-auto mb-1 w-full',
|
||||||
|
getManalinkAmountColor(amount)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(amount)}
|
||||||
</div>
|
</div>
|
||||||
|
<ShareIconButton
|
||||||
|
toastClassName={'-left-48 min-w-[250%]'}
|
||||||
|
buttonClassName={'transition-colors'}
|
||||||
|
onCopyButtonClassName={
|
||||||
|
'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600'
|
||||||
|
}
|
||||||
|
copyPayload={getManalinkUrl(link.slug)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className={clsx(
|
||||||
|
contractDetailsButtonClassName,
|
||||||
|
showDetails
|
||||||
|
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
|
||||||
|
: ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DotsHorizontalIcon className="h-[24px] w-5" />
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm">
|
||||||
|
{message || ''}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClaimsList(props: { link: Manalink; className: string }) {
|
||||||
|
const { link, className } = props
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Col className={clsx('px-4 py-2', className)}>
|
||||||
|
<div className="text-md mb-1 mt-2 w-full font-semibold">
|
||||||
|
Claimed by...
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
{link.claims.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{link.claims.map((claim) => (
|
||||||
|
<Row key={claim.txnId}>
|
||||||
|
<Claim claim={claim} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
No one has claimed this manalink yet! Share your manalink to start
|
||||||
|
spreading the wealth.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Claim(props: { claim: Claim }) {
|
||||||
|
const { claim } = props
|
||||||
|
const who = useUserById(claim.toId)
|
||||||
|
return (
|
||||||
|
<Row className="my-1 gap-2 text-xs">
|
||||||
|
<div>{who?.name || 'Loading...'}</div>
|
||||||
|
<div className="text-gray-500">{fromNow(claim.claimedTime)}</div>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManalinkGradient(amount: number) {
|
||||||
|
if (amount < 20) {
|
||||||
|
return 'from-indigo-200 via-indigo-500 to-indigo-800'
|
||||||
|
} else if (amount >= 20 && amount < 50) {
|
||||||
|
return 'from-fuchsia-200 via-fuchsia-500 to-fuchsia-800'
|
||||||
|
} else if (amount >= 50 && amount < 100) {
|
||||||
|
return 'from-rose-100 via-rose-400 to-rose-700'
|
||||||
|
} else if (amount >= 100) {
|
||||||
|
return 'from-amber-200 via-amber-500 to-amber-700'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManalinkAmountColor(amount: number) {
|
||||||
|
if (amount < 20) {
|
||||||
|
return 'text-indigo-500'
|
||||||
|
} else if (amount >= 20 && amount < 50) {
|
||||||
|
return 'text-fuchsia-600'
|
||||||
|
} else if (amount >= 50 && amount < 100) {
|
||||||
|
return 'text-rose-600'
|
||||||
|
} else if (amount >= 100) {
|
||||||
|
return 'text-amber-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4,12 +4,12 @@ import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card'
|
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
|
||||||
import { createManalink } from 'web/lib/firebase/manalinks'
|
import { createManalink } from 'web/lib/firebase/manalinks'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import Button from '../button'
|
import { Button } from '../button'
|
||||||
import { getManalinkUrl } from 'web/pages/links'
|
import { getManalinkUrl } from 'web/pages/links'
|
||||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
|
@ -66,12 +66,14 @@ function CreateManalinkForm(props: {
|
||||||
const defaultExpire = 'week'
|
const defaultExpire = 'week'
|
||||||
const [expiresIn, setExpiresIn] = useState(defaultExpire)
|
const [expiresIn, setExpiresIn] = useState(defaultExpire)
|
||||||
|
|
||||||
|
const defaultMessage = 'from ' + user.name
|
||||||
|
|
||||||
const [newManalink, setNewManalink] = useState<ManalinkInfo>({
|
const [newManalink, setNewManalink] = useState<ManalinkInfo>({
|
||||||
expiresTime: dayjs().add(1, defaultExpire).valueOf(),
|
expiresTime: dayjs().add(1, defaultExpire).valueOf(),
|
||||||
amount: 100,
|
amount: 100,
|
||||||
maxUses: 1,
|
maxUses: 1,
|
||||||
uses: 0,
|
uses: 0,
|
||||||
message: '',
|
message: defaultMessage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const EXPIRE_OPTIONS = {
|
const EXPIRE_OPTIONS = {
|
||||||
|
@ -161,7 +163,8 @@ function CreateManalinkForm(props: {
|
||||||
<div className="form-control w-full">
|
<div className="form-control w-full">
|
||||||
<label className="label">Message</label>
|
<label className="label">Message</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={`From ${user.name}`}
|
placeholder={defaultMessage}
|
||||||
|
maxLength={200}
|
||||||
className="input input-bordered resize-none"
|
className="input input-bordered resize-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={newManalink.message}
|
value={newManalink.message}
|
||||||
|
@ -189,11 +192,7 @@ function CreateManalinkForm(props: {
|
||||||
{finishedCreating && (
|
{finishedCreating && (
|
||||||
<>
|
<>
|
||||||
<Title className="!my-0" text="Manalink Created!" />
|
<Title className="!my-0" text="Manalink Created!" />
|
||||||
<ManalinkCardPreview
|
<ManalinkCard className="my-4" info={newManalink} preview />
|
||||||
className="my-4"
|
|
||||||
defaultMessage={`From ${user.name}`}
|
|
||||||
info={newManalink}
|
|
||||||
/>
|
|
||||||
<Row
|
<Row
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700',
|
'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700',
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import Router, { useRouter } from 'next/router'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||||
import { ManifoldLogo } from './manifold-logo'
|
import { ManifoldLogo } from './manifold-logo'
|
||||||
|
@ -24,13 +24,20 @@ import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||||
import { Group } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
// log out, and then reload the page, in case SSR wants to boot them out
|
||||||
|
// of whatever logged-in-only area of the site they might be in
|
||||||
|
await withTracking(firebaseLogout, 'sign out')()
|
||||||
|
await Router.replace(Router.asPath)
|
||||||
|
}
|
||||||
|
|
||||||
function getNavigation() {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
|
@ -40,6 +47,8 @@ function getNavigation() {
|
||||||
icon: NotificationsIcon,
|
icon: NotificationsIcon,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||||
|
|
||||||
...(IS_PRIVATE_MANIFOLD
|
...(IS_PRIVATE_MANIFOLD
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
@ -65,7 +74,6 @@ function getMoreNavigation(user?: User | null) {
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return [
|
return [
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
@ -74,15 +82,15 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: 'Send M$', href: '/links' },
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
{
|
{
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
onClick: logout,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -90,7 +98,6 @@ function getMoreNavigation(user?: User | null) {
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
||||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
@ -110,6 +117,7 @@ const signedOutMobileNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedInMobileNavigation = [
|
const signedInMobileNavigation = [
|
||||||
|
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||||
...(IS_PRIVATE_MANIFOLD
|
...(IS_PRIVATE_MANIFOLD
|
||||||
? []
|
? []
|
||||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||||
|
@ -125,15 +133,15 @@ function getMoreMobileNav() {
|
||||||
...(IS_PRIVATE_MANIFOLD
|
...(IS_PRIVATE_MANIFOLD
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{ name: 'Send M$', href: '/links' },
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
]),
|
]),
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
|
||||||
{
|
{
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
onClick: logout,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -205,15 +213,22 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
const privateUser = usePrivateUser(user?.id)
|
||||||
|
// usePing(user?.id)
|
||||||
|
|
||||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||||
const mobileNavigationOptions = !user
|
const mobileNavigationOptions = !user
|
||||||
? signedOutMobileNavigation
|
? signedOutMobileNavigation
|
||||||
: signedInMobileNavigation
|
: signedInMobileNavigation
|
||||||
|
|
||||||
const memberItems = (
|
const memberItems = (
|
||||||
useMemberGroups(user?.id, { withChatEnabled: true }) ?? []
|
useMemberGroups(
|
||||||
|
user?.id,
|
||||||
|
{ withChatEnabled: true },
|
||||||
|
{ by: 'mostRecentChatActivityTime' }
|
||||||
|
) ?? []
|
||||||
).map((group: Group) => ({
|
).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: groupPath(group.slug),
|
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -242,7 +257,10 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Spacer if there are any groups */}
|
||||||
|
{memberItems.length > 0 && (
|
||||||
|
<hr className="!my-4 mr-2 border-gray-300" />
|
||||||
|
)}
|
||||||
{privateUser && (
|
{privateUser && (
|
||||||
<GroupsList
|
<GroupsList
|
||||||
currentPage={router.asPath}
|
currentPage={router.asPath}
|
||||||
|
@ -265,11 +283,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spacer if there are any groups */}
|
{/* Spacer if there are any groups */}
|
||||||
{memberItems.length > 0 && (
|
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
||||||
<div className="py-3">
|
|
||||||
<div className="h-[1px] bg-gray-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{privateUser && (
|
{privateUser && (
|
||||||
<GroupsList
|
<GroupsList
|
||||||
currentPage={router.asPath}
|
currentPage={router.asPath}
|
||||||
|
@ -298,8 +312,18 @@ function GroupsList(props: {
|
||||||
|
|
||||||
// Set notification as seen if our current page is equal to the isSeenOnHref property
|
// Set notification as seen if our current page is equal to the isSeenOnHref property
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentPageWithoutQuery = currentPage.split('?')[0]
|
||||||
|
const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2]
|
||||||
preferredNotifications.forEach((notification) => {
|
preferredNotifications.forEach((notification) => {
|
||||||
if (notification.isSeenOnHref === currentPage) {
|
if (
|
||||||
|
notification.isSeenOnHref === currentPage ||
|
||||||
|
// Old chat style group chat notif was just /group/slug
|
||||||
|
(notification.isSeenOnHref &&
|
||||||
|
currentPageWithoutQuery.includes(notification.isSeenOnHref)) ||
|
||||||
|
// They're on the home page, so if they've a chat notif, they're seeing the chat
|
||||||
|
(notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) &&
|
||||||
|
currentPageWithoutQuery.endsWith(currentPageGroupSlug))
|
||||||
|
) {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -9,8 +9,8 @@ export function NumberInput(props: {
|
||||||
numberString: string
|
numberString: string
|
||||||
onChange: (newNumberString: string) => void
|
onChange: (newNumberString: string) => void
|
||||||
error: string | undefined
|
error: string | undefined
|
||||||
label: string
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
className?: string
|
className?: string
|
||||||
inputClassName?: string
|
inputClassName?: string
|
||||||
// Needed to focus the amount input
|
// Needed to focus the amount input
|
||||||
|
@ -21,8 +21,8 @@ export function NumberInput(props: {
|
||||||
numberString,
|
numberString,
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
label,
|
|
||||||
disabled,
|
disabled,
|
||||||
|
placeholder,
|
||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
@ -32,16 +32,17 @@ export function NumberInput(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group">
|
<label className="input-group">
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'input input-bordered max-w-[200px] text-lg',
|
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="0"
|
pattern="[0-9]*"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder={placeholder ?? '0'}
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
value={numberString}
|
value={numberString}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
83
web/components/online-user-list.tsx
Normal file
83
web/components/online-user-list.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Avatar } from './avatar'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { UserLink } from './user-page'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { UserCircleIcon } from '@heroicons/react/solid'
|
||||||
|
import { useUsers } from 'web/hooks/use-users'
|
||||||
|
import { partition } from 'lodash'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const isOnline = (user?: User) =>
|
||||||
|
user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000
|
||||||
|
|
||||||
|
export function OnlineUserList(props: { users: User[] }) {
|
||||||
|
let { users } = props
|
||||||
|
const liveUsers = useUsers().filter((user) =>
|
||||||
|
users.map((u) => u.id).includes(user.id)
|
||||||
|
)
|
||||||
|
if (liveUsers) users = liveUsers
|
||||||
|
const [onlineUsers, offlineUsers] = partition(users, (user) => isOnline(user))
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
|
// Subtract bottom bar when it's showing (less than lg screen)
|
||||||
|
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
|
||||||
|
const remainingHeight =
|
||||||
|
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
className="mt-4 flex-1 gap-1 hover:overflow-auto"
|
||||||
|
ref={setContainerRef}
|
||||||
|
style={{ height: remainingHeight }}
|
||||||
|
>
|
||||||
|
{onlineUsers
|
||||||
|
.concat(
|
||||||
|
offlineUsers.sort(
|
||||||
|
(a, b) => (b.lastPingTime ?? 0) - (a.lastPingTime ?? 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((user) => (
|
||||||
|
<Row
|
||||||
|
key={user.id}
|
||||||
|
className={clsx('items-center justify-between gap-2 p-2')}
|
||||||
|
>
|
||||||
|
<OnlineUserAvatar key={user.id} user={user} size={'sm'} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlineUserAvatar(props: {
|
||||||
|
user?: User
|
||||||
|
className?: string
|
||||||
|
size?: 'sm' | 'xs' | number
|
||||||
|
}) {
|
||||||
|
const { user, className, size } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className={clsx('relative items-center gap-2', className)}>
|
||||||
|
<Avatar
|
||||||
|
username={user?.username}
|
||||||
|
avatarUrl={user?.avatarUrl}
|
||||||
|
size={size}
|
||||||
|
className={!isOnline(user) ? 'opacity-50' : ''}
|
||||||
|
/>
|
||||||
|
{user && (
|
||||||
|
<UserLink
|
||||||
|
name={user.name}
|
||||||
|
username={user.username}
|
||||||
|
className={!isOnline(user) ? 'text-gray-500' : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOnline(user) && (
|
||||||
|
<div className="absolute left-0 top-0 ">
|
||||||
|
<UserCircleIcon className="text-primary bg-primary h-3 w-3 rounded-full border-2 border-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
|
@ -8,9 +8,11 @@ export function Page(props: {
|
||||||
rightSidebar?: ReactNode
|
rightSidebar?: ReactNode
|
||||||
suspend?: boolean
|
suspend?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
rightSidebarClassName?: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { children, rightSidebar, suspend, className } = props
|
const { children, rightSidebar, suspend, className, rightSidebarClassName } =
|
||||||
|
props
|
||||||
|
|
||||||
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
|
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
|
||||||
return (
|
return (
|
||||||
|
@ -37,7 +39,11 @@ export function Page(props: {
|
||||||
<div className="block xl:hidden">{rightSidebar}</div>
|
<div className="block xl:hidden">{rightSidebar}</div>
|
||||||
</main>
|
</main>
|
||||||
<aside className="hidden xl:col-span-3 xl:block">
|
<aside className="hidden xl:col-span-3 xl:block">
|
||||||
<div className="sticky top-4 space-y-4">{rightSidebar}</div>
|
<div
|
||||||
|
className={clsx('sticky top-4 space-y-4', rightSidebarClassName)}
|
||||||
|
>
|
||||||
|
{rightSidebar}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -56,4 +62,6 @@ const visuallyHiddenStyle = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: 1,
|
width: 1,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
visibility: 'hidden',
|
||||||
} as const
|
} as const
|
||||||
|
|
|
@ -1,17 +1,34 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function Pagination(props: {
|
export function Pagination(props: {
|
||||||
page: number
|
page: number
|
||||||
itemsPerPage: number
|
itemsPerPage: number
|
||||||
totalItems: number
|
totalItems: number
|
||||||
setPage: (page: number) => void
|
setPage: (page: number) => void
|
||||||
scrollToTop?: boolean
|
scrollToTop?: boolean
|
||||||
|
className?: string
|
||||||
|
nextTitle?: string
|
||||||
|
prevTitle?: string
|
||||||
}) {
|
}) {
|
||||||
const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props
|
const {
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
totalItems,
|
||||||
|
setPage,
|
||||||
|
scrollToTop,
|
||||||
|
nextTitle,
|
||||||
|
prevTitle,
|
||||||
|
className,
|
||||||
|
} = props
|
||||||
|
|
||||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
|
className={clsx(
|
||||||
|
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
aria-label="Pagination"
|
aria-label="Pagination"
|
||||||
>
|
>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
|
@ -25,19 +42,21 @@ export function Pagination(props: {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 justify-between sm:justify-end">
|
<div className="flex flex-1 justify-between sm:justify-end">
|
||||||
|
{page > 0 && (
|
||||||
<a
|
<a
|
||||||
href={scrollToTop ? '#' : undefined}
|
href={scrollToTop ? '#' : undefined}
|
||||||
className="relative inline-flex cursor-pointer select-none 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"
|
className="relative inline-flex cursor-pointer select-none 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 > 0 && setPage(page - 1)}
|
onClick={() => page > 0 && setPage(page - 1)}
|
||||||
>
|
>
|
||||||
Previous
|
{prevTitle ?? 'Previous'}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={scrollToTop ? '#' : undefined}
|
href={scrollToTop ? '#' : undefined}
|
||||||
className="relative ml-3 inline-flex cursor-pointer select-none 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"
|
className="relative ml-3 inline-flex cursor-pointer select-none 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 < maxPage && setPage(page + 1)}
|
onClick={() => page < maxPage && setPage(page + 1)}
|
||||||
>
|
>
|
||||||
Next
|
{nextTitle ?? 'Next'}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -19,6 +19,13 @@ export const PortfolioValueSection = memo(
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
|
||||||
|
// instead of "All time"
|
||||||
|
const allTimeLabel =
|
||||||
|
lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z')
|
||||||
|
? 'Since June'
|
||||||
|
: 'All time'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row className="gap-8">
|
<Row className="gap-8">
|
||||||
|
@ -39,7 +46,7 @@ export const PortfolioValueSection = memo(
|
||||||
setPortfolioPeriod(e.target.value as Period)
|
setPortfolioPeriod(e.target.value as Period)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="allTime">All time</option>
|
<option value="allTime">{allTimeLabel}</option>
|
||||||
<option value="weekly">7 days</option>
|
<option value="weekly">7 days</option>
|
||||||
<option value="daily">24 hours</option>
|
<option value="daily">24 hours</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||||
|
import { BucketInput } from './bucket-input'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
|
|
||||||
|
@ -6,10 +9,12 @@ export function ProbabilityInput(props: {
|
||||||
prob: number | undefined
|
prob: number | undefined
|
||||||
onChange: (newProb: number | undefined) => void
|
onChange: (newProb: number | undefined) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
className?: string
|
className?: string
|
||||||
inputClassName?: string
|
inputClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const { prob, onChange, disabled, className, inputClassName } = props
|
const { prob, onChange, disabled, placeholder, className, inputClassName } =
|
||||||
|
props
|
||||||
|
|
||||||
const onProbChange = (str: string) => {
|
const onProbChange = (str: string) => {
|
||||||
let prob = parseInt(str.replace(/\D/g, ''))
|
let prob = parseInt(str.replace(/\D/g, ''))
|
||||||
|
@ -27,7 +32,7 @@ export function ProbabilityInput(props: {
|
||||||
<label className="input-group">
|
<label className="input-group">
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'input input-bordered max-w-[200px] text-lg',
|
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
type="number"
|
type="number"
|
||||||
|
@ -35,7 +40,7 @@ export function ProbabilityInput(props: {
|
||||||
min={1}
|
min={1}
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="0"
|
placeholder={placeholder ?? '0'}
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
value={prob ?? ''}
|
value={prob ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -47,3 +52,43 @@ export function ProbabilityInput(props: {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProbabilityOrNumericInput(props: {
|
||||||
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
prob: number | undefined
|
||||||
|
setProb: (prob: number | undefined) => void
|
||||||
|
isSubmitting: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
const { contract, prob, setProb, isSubmitting, placeholder } = props
|
||||||
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
|
return isPseudoNumeric ? (
|
||||||
|
<BucketInput
|
||||||
|
contract={contract}
|
||||||
|
onBucketChange={(value) =>
|
||||||
|
setProb(
|
||||||
|
value === undefined
|
||||||
|
? undefined
|
||||||
|
: 100 *
|
||||||
|
getPseudoProbability(
|
||||||
|
value,
|
||||||
|
contract.min,
|
||||||
|
contract.max,
|
||||||
|
contract.isLogScale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProbabilityInput
|
||||||
|
inputClassName="w-full max-w-none"
|
||||||
|
prob={prob}
|
||||||
|
onChange={setProb}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -2,65 +2,48 @@ import React, { useState } from 'react'
|
||||||
import { ShareIcon } from '@heroicons/react/outline'
|
import { ShareIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
|
||||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
|
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
|
||||||
import { Group } from 'common/group'
|
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
|
||||||
|
|
||||||
function copyContractWithReferral(contract: Contract, username?: string) {
|
|
||||||
const postFix =
|
|
||||||
username && contract.creatorUsername !== username
|
|
||||||
? '?referrer=' + username
|
|
||||||
: ''
|
|
||||||
copyToClipboard(
|
|
||||||
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
|
|
||||||
function copyGroupWithReferral(group: Group, username?: string) {
|
|
||||||
const postFix = username ? '?referrer=' + username : ''
|
|
||||||
copyToClipboard(
|
|
||||||
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareIconButton(props: {
|
export function ShareIconButton(props: {
|
||||||
contract?: Contract
|
|
||||||
group?: Group
|
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
|
onCopyButtonClassName?: string
|
||||||
toastClassName?: string
|
toastClassName?: string
|
||||||
username?: string
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
copyPayload: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contract,
|
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
|
onCopyButtonClassName,
|
||||||
toastClassName,
|
toastClassName,
|
||||||
username,
|
|
||||||
group,
|
|
||||||
children,
|
children,
|
||||||
|
iconClassName,
|
||||||
|
copyPayload,
|
||||||
} = props
|
} = props
|
||||||
const [showToast, setShowToast] = useState(false)
|
const [showToast, setShowToast] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex-shrink-0">
|
<div className="relative z-10 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className={clsx(contractDetailsButtonClassName, buttonClassName)}
|
className={clsx(
|
||||||
|
contractDetailsButtonClassName,
|
||||||
|
buttonClassName,
|
||||||
|
showToast ? onCopyButtonClassName : ''
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (contract) copyContractWithReferral(contract, username)
|
copyToClipboard(copyPayload)
|
||||||
if (group) copyGroupWithReferral(group, username)
|
|
||||||
track('copy share link')
|
track('copy share link')
|
||||||
setShowToast(true)
|
setShowToast(true)
|
||||||
setTimeout(() => setShowToast(false), 2000)
|
setTimeout(() => setShowToast(false), 2000)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
|
<ShareIcon
|
||||||
|
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Contract, contractUrl } from 'web/lib/firebase/contracts'
|
|
||||||
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
|
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
|
||||||
import { CopyLinkButton } from './copy-link-button'
|
import { CopyLinkButton } from './copy-link-button'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
|
@ -7,18 +10,15 @@ import { Row } from './layout/row'
|
||||||
export function ShareMarket(props: { contract: Contract; className?: string }) {
|
export function ShareMarket(props: { contract: Contract; className?: string }) {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
|
||||||
|
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx(className, 'gap-3')}>
|
<Col className={clsx(className, 'gap-3')}>
|
||||||
<div>Share your market</div>
|
<div>Share your market</div>
|
||||||
<Row className="mb-6 items-center">
|
<Row className="mb-6 items-center">
|
||||||
<input
|
|
||||||
className="input input-bordered flex-1 rounded-r-none text-gray-500"
|
|
||||||
readOnly
|
|
||||||
type="text"
|
|
||||||
value={contractUrl(contract)}
|
|
||||||
/>
|
|
||||||
<CopyLinkButton
|
<CopyLinkButton
|
||||||
contract={contract}
|
url={url}
|
||||||
|
displayUrl={contractUrl(contract)}
|
||||||
buttonClassName="btn-md rounded-l-none"
|
buttonClassName="btn-md rounded-l-none"
|
||||||
toastClassName={'-left-28 mt-1'}
|
toastClassName={'-left-28 mt-1'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -38,6 +38,8 @@ import { GroupsButton } from 'web/components/groups/groups-button'
|
||||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { useUserBets } from 'web/hooks/use-user-bets'
|
import { useUserBets } from 'web/hooks/use-user-bets'
|
||||||
|
import { ReferralsButton } from 'web/components/referrals-button'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -122,6 +124,7 @@ export function UserPage(props: {
|
||||||
|
|
||||||
const yourFollows = useFollows(currentUser?.id)
|
const yourFollows = useFollows(currentUser?.id)
|
||||||
const isFollowing = yourFollows?.includes(user.id)
|
const isFollowing = yourFollows?.includes(user.id)
|
||||||
|
const profit = user.profitCached.allTime
|
||||||
|
|
||||||
const onFollow = () => {
|
const onFollow = () => {
|
||||||
if (!currentUser) return
|
if (!currentUser) return
|
||||||
|
@ -186,6 +189,17 @@ export function UserPage(props: {
|
||||||
<Col className="mx-4 -mt-6">
|
<Col className="mx-4 -mt-6">
|
||||||
<span className="text-2xl font-bold">{user.name}</span>
|
<span className="text-2xl font-bold">{user.name}</span>
|
||||||
<span className="text-gray-500">@{user.username}</span>
|
<span className="text-gray-500">@{user.username}</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-md',
|
||||||
|
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(profit)}
|
||||||
|
</span>{' '}
|
||||||
|
profit
|
||||||
|
</span>
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
|
@ -202,7 +216,9 @@ export function UserPage(props: {
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
{/* <ReferralsButton user={user} currentUser={currentUser} /> */}
|
{currentUser?.username === 'ian' && (
|
||||||
|
<ReferralsButton user={user} currentUser={currentUser} />
|
||||||
|
)}
|
||||||
<GroupsButton user={user} />
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,68 @@ import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { resolution } from 'common/contract'
|
import { resolution } from 'common/contract'
|
||||||
|
|
||||||
|
export function YesNoSelector(props: {
|
||||||
|
selected?: 'YES' | 'NO'
|
||||||
|
onSelect: (selected: 'YES' | 'NO') => void
|
||||||
|
className?: string
|
||||||
|
btnClassName?: string
|
||||||
|
replaceYesButton?: React.ReactNode
|
||||||
|
replaceNoButton?: React.ReactNode
|
||||||
|
isPseudoNumeric?: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
btnClassName,
|
||||||
|
replaceNoButton,
|
||||||
|
replaceYesButton,
|
||||||
|
isPseudoNumeric,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const commonClassNames =
|
||||||
|
'inline-flex items-center justify-center rounded-3xl border-2 p-2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className={clsx('space-x-3', className)}>
|
||||||
|
{replaceYesButton ? (
|
||||||
|
replaceYesButton
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
commonClassNames,
|
||||||
|
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
||||||
|
selected == 'YES'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-primary bg-transparent',
|
||||||
|
btnClassName
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect('YES')}
|
||||||
|
>
|
||||||
|
{isPseudoNumeric ? 'HIGHER' : 'YES'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{replaceNoButton ? (
|
||||||
|
replaceNoButton
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
commonClassNames,
|
||||||
|
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
|
||||||
|
selected == 'NO'
|
||||||
|
? 'bg-red-400 text-white'
|
||||||
|
: 'bg-transparent text-red-400',
|
||||||
|
btnClassName
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect('NO')}
|
||||||
|
>
|
||||||
|
{isPseudoNumeric ? 'LOWER' : 'NO'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function YesNoCancelSelector(props: {
|
export function YesNoCancelSelector(props: {
|
||||||
selected: resolution | undefined
|
selected: resolution | undefined
|
||||||
onSelect: (selected: resolution) => void
|
onSelect: (selected: resolution) => void
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const useAlgoFeed = (
|
||||||
getDefaultFeed().then((feed) => setAllFeed(feed))
|
getDefaultFeed().then((feed) => setAllFeed(feed))
|
||||||
} else setAllFeed(feed)
|
} else setAllFeed(feed)
|
||||||
|
|
||||||
trackLatency('feed', getTime())
|
trackLatency(user.id, 'feed', getTime())
|
||||||
console.log('"all" feed load time', getTime())
|
console.log('"all" feed load time', getTime())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,16 @@ export const useFollows = (userId: string | null | undefined) => {
|
||||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId) return listenForFollows(userId, setFollowIds)
|
if (userId) {
|
||||||
|
const key = `follows:${userId}`
|
||||||
|
const follows = localStorage.getItem(key)
|
||||||
|
if (follows) setFollowIds(JSON.parse(follows))
|
||||||
|
|
||||||
|
return listenForFollows(userId, (follows) => {
|
||||||
|
setFollowIds(follows)
|
||||||
|
localStorage.setItem(key, JSON.stringify(follows))
|
||||||
|
})
|
||||||
|
}
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
return followIds
|
return followIds
|
||||||
|
|
|
@ -2,13 +2,15 @@ import { useEffect, useState } from 'react'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getGroupsWithContractId,
|
|
||||||
listenForGroup,
|
listenForGroup,
|
||||||
listenForGroups,
|
listenForGroups,
|
||||||
listenForMemberGroups,
|
listenForMemberGroups,
|
||||||
|
listGroups,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser, getUsers } from 'web/lib/firebase/users'
|
import { getUser, getUsers } from 'web/lib/firebase/users'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
export const useGroup = (groupId: string | undefined) => {
|
export const useGroup = (groupId: string | undefined) => {
|
||||||
const [group, setGroup] = useState<Group | null | undefined>()
|
const [group, setGroup] = useState<Group | null | undefined>()
|
||||||
|
@ -32,19 +34,27 @@ export const useGroups = () => {
|
||||||
|
|
||||||
export const useMemberGroups = (
|
export const useMemberGroups = (
|
||||||
userId: string | null | undefined,
|
userId: string | null | undefined,
|
||||||
options?: { withChatEnabled: boolean }
|
options?: { withChatEnabled: boolean },
|
||||||
|
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
||||||
) => {
|
) => {
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId)
|
if (userId)
|
||||||
return listenForMemberGroups(userId, (groups) => {
|
return listenForMemberGroups(
|
||||||
|
userId,
|
||||||
|
(groups) => {
|
||||||
if (options?.withChatEnabled)
|
if (options?.withChatEnabled)
|
||||||
return setMemberGroups(
|
return setMemberGroups(
|
||||||
filterDefined(groups.filter((group) => group.chatDisabled !== true))
|
filterDefined(
|
||||||
|
groups.filter((group) => group.chatDisabled !== true)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return setMemberGroups(groups)
|
return setMemberGroups(groups)
|
||||||
})
|
},
|
||||||
}, [options?.withChatEnabled, userId])
|
sort
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [options?.withChatEnabled, sort?.by, userId])
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,19 +98,22 @@ export async function listMembers(group: Group, max?: number) {
|
||||||
const { memberIds } = group
|
const { memberIds } = group
|
||||||
const numToRetrieve = max ?? memberIds.length
|
const numToRetrieve = max ?? memberIds.length
|
||||||
if (memberIds.length === 0) return []
|
if (memberIds.length === 0) return []
|
||||||
if (numToRetrieve)
|
if (numToRetrieve > 100)
|
||||||
return (await getUsers()).filter((user) =>
|
return (await getUsers()).filter((user) =>
|
||||||
group.memberIds.includes(user.id)
|
group.memberIds.includes(user.id)
|
||||||
)
|
)
|
||||||
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
|
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsWithContract = (contractId: string | undefined) => {
|
export const useGroupsWithContract = (contract: Contract) => {
|
||||||
const [groups, setGroups] = useState<Group[] | null | undefined>()
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId) getGroupsWithContractId(contractId, setGroups)
|
if (contract.groupSlugs)
|
||||||
}, [contractId])
|
listGroups(uniq(contract.groupSlugs)).then((groups) =>
|
||||||
|
setGroups(filterDefined(groups))
|
||||||
|
)
|
||||||
|
}, [contract.groupSlugs])
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
||||||
import { Notification } from 'common/notification'
|
import { Notification } from 'common/notification'
|
||||||
import {
|
import {
|
||||||
|
@ -6,7 +6,7 @@ import {
|
||||||
listenForNotifications,
|
listenForNotifications,
|
||||||
} from 'web/lib/firebase/notifications'
|
} from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map } from 'lodash'
|
import { groupBy, map } from 'lodash'
|
||||||
import { useFirestoreQuery } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||||
|
|
||||||
export type NotificationGroup = {
|
export type NotificationGroup = {
|
||||||
|
@ -19,36 +19,33 @@ export type NotificationGroup = {
|
||||||
|
|
||||||
// For some reason react-query subscriptions don't actually listen for notifications
|
// For some reason react-query subscriptions don't actually listen for notifications
|
||||||
// Use useUnseenPreferredNotificationGroups to listen for new notifications
|
// Use useUnseenPreferredNotificationGroups to listen for new notifications
|
||||||
export function usePreferredGroupedNotifications(privateUser: PrivateUser) {
|
export function usePreferredGroupedNotifications(
|
||||||
const [notificationGroups, setNotificationGroups] = useState<
|
privateUser: PrivateUser,
|
||||||
NotificationGroup[] | undefined
|
cachedNotifications?: Notification[]
|
||||||
>(undefined)
|
) {
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const result = useFirestoreQueryData(
|
||||||
const key = `notifications-${privateUser.id}-all`
|
['notifications-all', privateUser.id],
|
||||||
|
getNotificationsQuery(privateUser.id)
|
||||||
const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id))
|
|
||||||
useEffect(() => {
|
|
||||||
if (result.isLoading) return
|
|
||||||
if (!result.data) return setNotifications([])
|
|
||||||
const notifications = result.data.docs.map(
|
|
||||||
(doc) => doc.data() as Notification
|
|
||||||
)
|
)
|
||||||
|
const notifications = useMemo(() => {
|
||||||
|
if (result.isLoading) return cachedNotifications ?? []
|
||||||
|
if (!result.data) return cachedNotifications ?? []
|
||||||
|
const notifications = result.data as Notification[]
|
||||||
|
|
||||||
const notificationsToShow = getAppropriateNotifications(
|
return getAppropriateNotifications(
|
||||||
notifications,
|
notifications,
|
||||||
privateUser.notificationPreferences
|
privateUser.notificationPreferences
|
||||||
).filter((n) => !n.isSeenOnHref)
|
).filter((n) => !n.isSeenOnHref)
|
||||||
setNotifications(notificationsToShow)
|
}, [
|
||||||
}, [privateUser.notificationPreferences, result.data, result.isLoading])
|
cachedNotifications,
|
||||||
|
privateUser.notificationPreferences,
|
||||||
|
result.data,
|
||||||
|
result.isLoading,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
return useMemo(() => {
|
||||||
if (!notifications) return
|
if (notifications) return groupNotifications(notifications)
|
||||||
|
|
||||||
const groupedNotifications = groupNotifications(notifications)
|
|
||||||
setNotificationGroups(groupedNotifications)
|
|
||||||
}, [notifications])
|
}, [notifications])
|
||||||
|
|
||||||
return notificationGroups
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {
|
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {
|
||||||
|
|
16
web/hooks/use-ping.ts
Normal file
16
web/hooks/use-ping.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { updateUser } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
|
export const usePing = (userId: string | undefined) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
updateUser(userId, {
|
||||||
|
lastPingTime: Date.now(),
|
||||||
|
})
|
||||||
|
}, 1000 * 30)
|
||||||
|
|
||||||
|
return () => clearInterval(pingInterval)
|
||||||
|
}, [userId])
|
||||||
|
}
|
27
web/hooks/use-save-referral.ts
Normal file
27
web/hooks/use-save-referral.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { User, writeReferralInfo } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
|
export const useSaveReferral = (
|
||||||
|
user?: User | null,
|
||||||
|
options?: {
|
||||||
|
defaultReferrer?: string
|
||||||
|
contractId?: string
|
||||||
|
groupId?: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { referrer } = router.query as {
|
||||||
|
referrer?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualReferrer = referrer || options?.defaultReferrer
|
||||||
|
|
||||||
|
if (!user && router.isReady && actualReferrer) {
|
||||||
|
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
|
||||||
|
}
|
||||||
|
}, [user, router, options])
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { trackView } from 'web/lib/firebase/tracking'
|
import { trackView } from 'web/lib/firebase/tracking'
|
||||||
import { useIsVisible } from './use-is-visible'
|
import { useIsVisible } from './use-is-visible'
|
||||||
|
import { useUser } from './use-user'
|
||||||
|
|
||||||
export const useSeenContracts = () => {
|
export const useSeenContracts = () => {
|
||||||
const [seenContracts, setSeenContracts] = useState<{
|
const [seenContracts, setSeenContracts] = useState<{
|
||||||
|
@ -21,18 +22,19 @@ export const useSaveSeenContract = (
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
const isVisible = useIsVisible(elem)
|
const isVisible = useIsVisible(elem)
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible && user) {
|
||||||
const newSeenContracts = {
|
const newSeenContracts = {
|
||||||
...getSeenContracts(),
|
...getSeenContracts(),
|
||||||
[contract.id]: Date.now(),
|
[contract.id]: Date.now(),
|
||||||
}
|
}
|
||||||
localStorage.setItem(key, JSON.stringify(newSeenContracts))
|
localStorage.setItem(key, JSON.stringify(newSeenContracts))
|
||||||
|
|
||||||
trackView(contract.id)
|
trackView(user.id, contract.id)
|
||||||
}
|
}
|
||||||
}, [isVisible, contract])
|
}, [isVisible, user, contract])
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = 'feed-seen-contracts'
|
const key = 'feed-seen-contracts'
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchBox } from 'react-instantsearch-hooks-web'
|
import { useSearchBox } from 'react-instantsearch-hooks-web'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { DEFAULT_SORT } from 'web/components/contract-search'
|
||||||
|
|
||||||
const MARKETS_SORT = 'markets_sort'
|
const MARKETS_SORT = 'markets_sort'
|
||||||
|
|
||||||
|
@ -10,16 +11,11 @@ export type Sort =
|
||||||
| 'newest'
|
| 'newest'
|
||||||
| 'oldest'
|
| 'oldest'
|
||||||
| 'most-traded'
|
| 'most-traded'
|
||||||
| 'most-popular'
|
|
||||||
| '24-hour-vol'
|
| '24-hour-vol'
|
||||||
| 'close-date'
|
| 'close-date'
|
||||||
| 'resolve-date'
|
| 'resolve-date'
|
||||||
| 'last-updated'
|
| 'last-updated'
|
||||||
|
| 'score'
|
||||||
export function checkAgainstQuery(query: string, corpus: string) {
|
|
||||||
const queryWords = query.toLowerCase().split(' ')
|
|
||||||
return queryWords.every((word) => corpus.toLowerCase().includes(word))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSavedSort() {
|
export function getSavedSort() {
|
||||||
// TODO: this obviously doesn't work with SSR, common sense would suggest
|
// TODO: this obviously doesn't work with SSR, common sense would suggest
|
||||||
|
@ -36,7 +32,7 @@ export function useInitialQueryAndSort(options?: {
|
||||||
shouldLoadFromStorage?: boolean
|
shouldLoadFromStorage?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
|
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
|
||||||
defaultSort: 'most-popular',
|
defaultSort: DEFAULT_SORT,
|
||||||
shouldLoadFromStorage: true,
|
shouldLoadFromStorage: true,
|
||||||
})
|
})
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -58,9 +54,12 @@ export function useInitialQueryAndSort(options?: {
|
||||||
console.log('ready loading from storage ', sort ?? defaultSort)
|
console.log('ready loading from storage ', sort ?? defaultSort)
|
||||||
const localSort = getSavedSort()
|
const localSort = getSavedSort()
|
||||||
if (localSort) {
|
if (localSort) {
|
||||||
router.query.s = localSort
|
|
||||||
// Use replace to not break navigating back.
|
// Use replace to not break navigating back.
|
||||||
router.replace(router, undefined, { shallow: true })
|
router.replace(
|
||||||
|
{ query: { ...router.query, s: localSort } },
|
||||||
|
undefined,
|
||||||
|
{ shallow: true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
setInitialSort(localSort ?? defaultSort)
|
setInitialSort(localSort ?? defaultSort)
|
||||||
} else {
|
} else {
|
||||||
|
@ -84,7 +83,9 @@ export function useUpdateQueryAndSort(props: {
|
||||||
const setSort = (sort: Sort | undefined) => {
|
const setSort = (sort: Sort | undefined) => {
|
||||||
if (sort !== router.query.s) {
|
if (sort !== router.query.s) {
|
||||||
router.query.s = sort
|
router.query.s = sort
|
||||||
router.push(router, undefined, { shallow: true })
|
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
if (shouldLoadFromStorage) {
|
if (shouldLoadFromStorage) {
|
||||||
localStorage.setItem(MARKETS_SORT, sort || '')
|
localStorage.setItem(MARKETS_SORT, sort || '')
|
||||||
}
|
}
|
||||||
|
@ -102,7 +103,9 @@ export function useUpdateQueryAndSort(props: {
|
||||||
} else {
|
} else {
|
||||||
delete router.query.q
|
delete router.query.q
|
||||||
}
|
}
|
||||||
router.push(router, undefined, { shallow: true })
|
router.replace({ query: router.query }, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
track('search', { query })
|
track('search', { query })
|
||||||
}, 500),
|
}, 500),
|
||||||
[router]
|
[router]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useContext, useEffect, useState } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
|
@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
listenForLogin,
|
|
||||||
listenForPrivateUser,
|
listenForPrivateUser,
|
||||||
listenForUser,
|
|
||||||
User,
|
User,
|
||||||
users,
|
users,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { AuthContext } from 'web/components/auth-context'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
|
||||||
|
|
||||||
export const useUser = () => {
|
export const useUser = () => {
|
||||||
const [user, setUser] = useStateCheckEquality<User | null | undefined>(
|
return useContext(AuthContext)
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => listenForLogin(setUser), [setUser])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
identifyUser(user.id)
|
|
||||||
setUserProperty('username', user.username)
|
|
||||||
|
|
||||||
return listenForUser(user.id, setUser)
|
|
||||||
}
|
|
||||||
}, [user, setUser])
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePrivateUser = (userId?: string) => {
|
export const usePrivateUser = (userId?: string) => {
|
||||||
|
|
|
@ -1,32 +1,28 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { PrivateUser, User } from 'common/user'
|
import { PrivateUser, User } from 'common/user'
|
||||||
import {
|
|
||||||
listenForAllUsers,
|
|
||||||
listenForPrivateUsers,
|
|
||||||
} from 'web/lib/firebase/users'
|
|
||||||
import { groupBy, sortBy, difference } from 'lodash'
|
import { groupBy, sortBy, difference } from 'lodash'
|
||||||
import { getContractsOfUserBets } from 'web/lib/firebase/bets'
|
import { getContractsOfUserBets } from 'web/lib/firebase/bets'
|
||||||
import { useFollows } from './use-follows'
|
import { useFollows } from './use-follows'
|
||||||
import { useUser } from './use-user'
|
import { useUser } from './use-user'
|
||||||
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
|
import { DocumentData } from 'firebase/firestore'
|
||||||
|
import { users, privateUsers } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
export const useUsers = () => {
|
export const useUsers = () => {
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
|
||||||
|
subscribe: true,
|
||||||
useEffect(() => {
|
includeMetadataChanges: true,
|
||||||
listenForAllUsers(setUsers)
|
})
|
||||||
}, [])
|
return result.data ?? []
|
||||||
|
|
||||||
return users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePrivateUsers = () => {
|
export const usePrivateUsers = () => {
|
||||||
const [users, setUsers] = useState<PrivateUser[]>([])
|
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
|
||||||
|
['private users'],
|
||||||
useEffect(() => {
|
privateUsers,
|
||||||
listenForPrivateUsers(setUsers)
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
}, [])
|
)
|
||||||
|
return result.data || []
|
||||||
return users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDiscoverUsers = (userId: string | null | undefined) => {
|
export const useDiscoverUsers = (userId: string | null | undefined) => {
|
||||||
|
|
54
web/lib/firebase/auth.ts
Normal file
54
web/lib/firebase/auth.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { PROJECT_ID } from 'common/envs/constants'
|
||||||
|
import { setCookie, getCookies } from '../util/cookie'
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
|
||||||
|
const TOKEN_KINDS = ['refresh', 'id'] as const
|
||||||
|
type TokenKind = typeof TOKEN_KINDS[number]
|
||||||
|
|
||||||
|
const getAuthCookieName = (kind: TokenKind) => {
|
||||||
|
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
|
||||||
|
return `FIREBASE_TOKEN_${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_COOKIE_NAME = getAuthCookieName('id')
|
||||||
|
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
|
||||||
|
|
||||||
|
export const getAuthCookies = (request?: IncomingMessage) => {
|
||||||
|
const data = request != null ? request.headers.cookie ?? '' : document.cookie
|
||||||
|
const cookies = getCookies(data)
|
||||||
|
return {
|
||||||
|
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
|
||||||
|
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAuthCookies = (
|
||||||
|
idToken?: string,
|
||||||
|
refreshToken?: string,
|
||||||
|
response?: ServerResponse
|
||||||
|
) => {
|
||||||
|
// these tokens last an hour
|
||||||
|
const idMaxAge = idToken != null ? 60 * 60 : 0
|
||||||
|
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
|
||||||
|
['path', '/'],
|
||||||
|
['max-age', idMaxAge.toString()],
|
||||||
|
['samesite', 'lax'],
|
||||||
|
['secure'],
|
||||||
|
])
|
||||||
|
// these tokens don't expire
|
||||||
|
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0
|
||||||
|
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
|
||||||
|
['path', '/'],
|
||||||
|
['max-age', refreshMaxAge.toString()],
|
||||||
|
['samesite', 'lax'],
|
||||||
|
['secure'],
|
||||||
|
])
|
||||||
|
if (response != null) {
|
||||||
|
response.setHeader('Set-Cookie', [idCookie, refreshCookie])
|
||||||
|
} else {
|
||||||
|
document.cookie = idCookie
|
||||||
|
document.cookie = refreshCookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteAuthCookies = () => setAuthCookies()
|
|
@ -1,17 +1,17 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
doc,
|
|
||||||
setDoc,
|
|
||||||
deleteDoc,
|
|
||||||
where,
|
|
||||||
collection,
|
collection,
|
||||||
query,
|
deleteDoc,
|
||||||
getDocs,
|
doc,
|
||||||
orderBy,
|
|
||||||
getDoc,
|
getDoc,
|
||||||
updateDoc,
|
getDocs,
|
||||||
limit,
|
limit,
|
||||||
|
orderBy,
|
||||||
|
query,
|
||||||
|
setDoc,
|
||||||
startAfter,
|
startAfter,
|
||||||
|
updateDoc,
|
||||||
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, sum } from 'lodash'
|
import { sortBy, sum } from 'lodash'
|
||||||
|
|
||||||
|
@ -129,6 +129,7 @@ export async function listContractsByGroupSlug(
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
const q = query(contracts, where('groupSlugs', 'array-contains', slug))
|
const q = query(contracts, where('groupSlugs', 'array-contains', slug))
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
|
console.log(snapshot.docs.map((doc) => doc.data()))
|
||||||
return snapshot.docs.map((doc) => doc.data())
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
where,
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, uniq } from 'lodash'
|
import { sortBy, uniq } from 'lodash'
|
||||||
import { Group } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||||
import { updateContract } from './contracts'
|
import { updateContract } from './contracts'
|
||||||
import {
|
import {
|
||||||
coll,
|
coll,
|
||||||
|
@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings'
|
subpath?:
|
||||||
|
| 'edit'
|
||||||
|
| 'markets'
|
||||||
|
| 'about'
|
||||||
|
| typeof GROUP_CHAT_SLUG
|
||||||
|
| 'leaderboards'
|
||||||
) {
|
) {
|
||||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||||
}
|
}
|
||||||
|
@ -39,6 +44,10 @@ export async function listAllGroups() {
|
||||||
return getValues<Group>(groups)
|
return getValues<Group>(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGroups(groupSlugs: string[]) {
|
||||||
|
return Promise.all(groupSlugs.map(getGroupBySlug))
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groups, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
@ -62,29 +71,38 @@ export function listenForGroup(
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void,
|
||||||
|
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('memberIds', 'array-contains', userId))
|
const q = query(groups, where('memberIds', 'array-contains', userId))
|
||||||
|
const sorter = (group: Group) => {
|
||||||
|
if (sort?.by === 'mostRecentChatActivityTime') {
|
||||||
|
return group.mostRecentChatActivityTime ?? group.createdTime
|
||||||
|
}
|
||||||
|
if (sort?.by === 'mostRecentContractAddedTime') {
|
||||||
|
return group.mostRecentContractAddedTime ?? group.createdTime
|
||||||
|
}
|
||||||
|
return group.mostRecentActivityTime
|
||||||
|
}
|
||||||
return listenForValues<Group>(q, (groups) => {
|
return listenForValues<Group>(q, (groups) => {
|
||||||
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
const sorted = sortBy(groups, [(group) => -sorter(group)])
|
||||||
setGroups(sorted)
|
setGroups(sorted)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupsWithContractId(
|
export async function listenForGroupsWithContractId(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
||||||
setGroups(await getValues<Group>(q))
|
return listenForValues<Group>(q, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
|
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||||
// get group to get the member ids
|
// get group to get the member ids
|
||||||
const group = await getGroupBySlug(groupSlug)
|
const group = await getGroup(groupId)
|
||||||
if (!group) {
|
if (!group) {
|
||||||
console.error(`Group not found: ${groupSlug}`)
|
console.error(`Group not found: ${groupId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return await joinGroup(group, userId)
|
return await joinGroup(group, userId)
|
||||||
|
@ -106,9 +124,27 @@ export async function leaveGroup(group: Group, userId: string): Promise<void> {
|
||||||
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addContractToGroup(group: Group, contract: Contract) {
|
export async function addContractToGroup(
|
||||||
|
group: Group,
|
||||||
|
contract: Contract,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // already in that group
|
||||||
|
|
||||||
|
const newGroupLinks = [
|
||||||
|
...(contract.groupLinks ?? []),
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
slug: group.slug,
|
||||||
|
userId,
|
||||||
|
name: group.name,
|
||||||
|
} as GroupLink,
|
||||||
|
]
|
||||||
|
|
||||||
await updateContract(contract.id, {
|
await updateContract(contract.id, {
|
||||||
groupSlugs: [...(contract.groupSlugs ?? []), group.slug],
|
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||||
|
groupLinks: newGroupLinks,
|
||||||
})
|
})
|
||||||
return await updateGroup(group, {
|
return await updateGroup(group, {
|
||||||
contractIds: uniq([...group.contractIds, contract.id]),
|
contractIds: uniq([...group.contractIds, contract.id]),
|
||||||
|
@ -120,8 +156,47 @@ export async function addContractToGroup(group: Group, contract: Contract) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setContractGroupSlugs(group: Group, contractId: string) {
|
export async function removeContractFromGroup(
|
||||||
await updateContract(contractId, { groupSlugs: [group.slug] })
|
group: Group,
|
||||||
|
contract: Contract
|
||||||
|
) {
|
||||||
|
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // not in that group
|
||||||
|
|
||||||
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
|
(link) => link.slug !== group.slug
|
||||||
|
)
|
||||||
|
await updateContract(contract.id, {
|
||||||
|
groupSlugs:
|
||||||
|
contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [],
|
||||||
|
groupLinks: newGroupLinks ?? [],
|
||||||
|
})
|
||||||
|
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
|
||||||
|
return await updateGroup(group, {
|
||||||
|
contractIds: uniq(newContractIds),
|
||||||
|
})
|
||||||
|
.then(() => group)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('error removing contract from group', err)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContractGroupLinks(
|
||||||
|
group: Group,
|
||||||
|
contractId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
await updateContract(contractId, {
|
||||||
|
groupLinks: [
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
name: group.name,
|
||||||
|
slug: group.slug,
|
||||||
|
userId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as GroupLink,
|
||||||
|
],
|
||||||
|
})
|
||||||
return await updateGroup(group, {
|
return await updateGroup(group, {
|
||||||
contractIds: uniq([...group.contractIds, contractId]),
|
contractIds: uniq([...group.contractIds, contractId]),
|
||||||
})
|
})
|
||||||
|
|
93
web/lib/firebase/server-auth.ts
Normal file
93
web/lib/firebase/server-auth.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
|
||||||
|
import { getAuthCookies, setAuthCookies } from './auth'
|
||||||
|
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
|
||||||
|
|
||||||
|
const ensureApp = async () => {
|
||||||
|
// Note: firebase-admin can only be imported from a server context,
|
||||||
|
// because it relies on Node standard library dependencies.
|
||||||
|
if (admin.apps.length === 0) {
|
||||||
|
// never initialize twice
|
||||||
|
return admin.initializeApp({ projectId: PROJECT_ID })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return admin.apps[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestFirebaseIdToken = async (refreshToken: string) => {
|
||||||
|
// See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token
|
||||||
|
const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token')
|
||||||
|
refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey)
|
||||||
|
const result = await fetch(refreshUrl.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`Could not refresh ID token: ${await result.text()}`)
|
||||||
|
}
|
||||||
|
return (await result.json()) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestContext = {
|
||||||
|
req: IncomingMessage
|
||||||
|
res: ServerResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
|
||||||
|
const app = await ensureApp()
|
||||||
|
const auth = app.auth()
|
||||||
|
const { idToken, refreshToken } = getAuthCookies(ctx.req)
|
||||||
|
|
||||||
|
// If we have a valid ID token, verify the user immediately with no network trips.
|
||||||
|
// If the ID token doesn't verify, we'll have to refresh it to see who they are.
|
||||||
|
// If they don't have any tokens, then we have no idea who they are.
|
||||||
|
if (idToken != null) {
|
||||||
|
try {
|
||||||
|
return (await auth.verifyIdToken(idToken))?.uid
|
||||||
|
} catch {
|
||||||
|
// plausibly expired; try the refresh token, if it's present
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refreshToken != null) {
|
||||||
|
try {
|
||||||
|
const resp = await requestFirebaseIdToken(refreshToken)
|
||||||
|
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
|
||||||
|
return (await auth.verifyIdToken(resp.id_token))?.uid
|
||||||
|
} catch (e) {
|
||||||
|
// this is a big unexpected problem -- either their cookies are corrupt
|
||||||
|
// or the refresh token API is down. functionally, they are not logged in
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
|
||||||
|
return async (ctx: GetServerSidePropsContext) => {
|
||||||
|
const uid = await getServerAuthenticatedUid(ctx)
|
||||||
|
if (uid == null) {
|
||||||
|
return fn != null ? await fn(ctx) : { props: {} }
|
||||||
|
} else {
|
||||||
|
return { redirect: { destination: dest, permanent: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
|
||||||
|
return async (ctx: GetServerSidePropsContext) => {
|
||||||
|
const uid = await getServerAuthenticatedUid(ctx)
|
||||||
|
if (uid == null) {
|
||||||
|
return { redirect: { destination: dest, permanent: false } }
|
||||||
|
} else {
|
||||||
|
return fn != null ? await fn(ctx) : { props: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage'
|
import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
import { storage } from './init'
|
import { storage } from './init'
|
||||||
|
|
||||||
// TODO: compress large images
|
// TODO: compress large images
|
||||||
|
@ -7,7 +8,10 @@ export const uploadImage = async (
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: (progress: number, isRunning: boolean) => void
|
onProgress?: (progress: number, isRunning: boolean) => void
|
||||||
) => {
|
) => {
|
||||||
const storageRef = ref(storage, `user-images/${username}/${file.name}`)
|
// Replace filename with a nanoid to avoid collisions
|
||||||
|
const [, ext] = file.name.split('.')
|
||||||
|
const filename = `${nanoid(10)}.${ext}`
|
||||||
|
const storageRef = ref(storage, `user-images/${username}/${filename}`)
|
||||||
const uploadTask = uploadBytesResumable(storageRef, file)
|
const uploadTask = uploadBytesResumable(storageRef, file)
|
||||||
|
|
||||||
let resolvePromise: (url: string) => void
|
let resolvePromise: (url: string) => void
|
||||||
|
|
|
@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore'
|
||||||
|
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { ClickEvent, LatencyEvent, View } from 'common/tracking'
|
import { ClickEvent, LatencyEvent, View } from 'common/tracking'
|
||||||
import { listenForLogin, User } from './users'
|
|
||||||
|
|
||||||
let user: User | null = null
|
export async function trackView(userId: string, contractId: string) {
|
||||||
if (typeof window !== 'undefined') {
|
const ref = doc(collection(db, 'private-users', userId, 'views'))
|
||||||
listenForLogin((u) => (user = u))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trackView(contractId: string) {
|
|
||||||
if (!user) return
|
|
||||||
const ref = doc(collection(db, 'private-users', user.id, 'views'))
|
|
||||||
|
|
||||||
const view: View = {
|
const view: View = {
|
||||||
contractId,
|
contractId,
|
||||||
|
@ -21,9 +14,8 @@ export async function trackView(contractId: string) {
|
||||||
return await setDoc(ref, view)
|
return await setDoc(ref, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trackClick(contractId: string) {
|
export async function trackClick(userId: string, contractId: string) {
|
||||||
if (!user) return
|
const ref = doc(collection(db, 'private-users', userId, 'events'))
|
||||||
const ref = doc(collection(db, 'private-users', user.id, 'events'))
|
|
||||||
|
|
||||||
const clickEvent: ClickEvent = {
|
const clickEvent: ClickEvent = {
|
||||||
type: 'click',
|
type: 'click',
|
||||||
|
@ -35,11 +27,11 @@ export async function trackClick(contractId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trackLatency(
|
export async function trackLatency(
|
||||||
|
userId: string,
|
||||||
type: 'feed' | 'portfolio',
|
type: 'feed' | 'portfolio',
|
||||||
latency: number
|
latency: number
|
||||||
) {
|
) {
|
||||||
if (!user) return
|
const ref = doc(collection(db, 'private-users', userId, 'latency'))
|
||||||
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
|
|
||||||
|
|
||||||
const latencyEvent: LatencyEvent = {
|
const latencyEvent: LatencyEvent = {
|
||||||
type,
|
type,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user