Merge branch 'main' into editor-market
This commit is contained in:
commit
023a404b5f
|
@ -139,7 +139,7 @@ export const OUTCOME_TYPES = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_QUESTION_LENGTH = 480
|
export const MAX_QUESTION_LENGTH = 480
|
||||||
export const MAX_DESCRIPTION_LENGTH = 10000
|
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||||
export const MAX_TAG_LENGTH = 60
|
export const MAX_TAG_LENGTH = 60
|
||||||
|
|
||||||
export const CPMM_MIN_POOL_QTY = 0.01
|
export const CPMM_MIN_POOL_QTY = 0.01
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
export function filterDefined<T>(array: (T | null | undefined)[]) {
|
export function filterDefined<T>(array: (T | null | undefined)[]) {
|
||||||
return array.filter((item) => item !== null && item !== undefined) as T[]
|
return array.filter((item) => item !== null && item !== undefined) as T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildArray<T>(
|
||||||
|
...params: (T | T[] | false | undefined | null)[]
|
||||||
|
) {
|
||||||
|
const array: T[] = []
|
||||||
|
|
||||||
|
for (const el of params) {
|
||||||
|
if (Array.isArray(el)) {
|
||||||
|
array.push(...el)
|
||||||
|
} else if (el) {
|
||||||
|
array.push(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
27
functions/src/scripts/set-avatar-cache-headers.ts
Normal file
27
functions/src/scripts/set-avatar-cache-headers.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { log } from '../utils'
|
||||||
|
|
||||||
|
const app = initAdmin()
|
||||||
|
const ONE_YEAR_SECS = 60 * 60 * 24 * 365
|
||||||
|
const AVATAR_EXTENSION_RE = /\.(gif|tiff|jpe?g|png|webp)$/i
|
||||||
|
|
||||||
|
const processAvatars = async () => {
|
||||||
|
const storage = app.storage()
|
||||||
|
const bucket = storage.bucket(`${app.options.projectId}.appspot.com`)
|
||||||
|
const [files] = await bucket.getFiles({ prefix: 'user-images' })
|
||||||
|
log(`${files.length} avatar images to process.`)
|
||||||
|
for (const file of files) {
|
||||||
|
if (AVATAR_EXTENSION_RE.test(file.name)) {
|
||||||
|
log(`Updating metadata for ${file.name}.`)
|
||||||
|
await file.setMetadata({
|
||||||
|
cacheControl: `public, max-age=${ONE_YEAR_SECS}`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log(`Skipping ${file.name} because it probably isn't an avatar.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
processAvatars().catch((e) => console.error(e))
|
||||||
|
}
|
|
@ -47,14 +47,21 @@ export function Avatar(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
|
export function EmptyAvatar(props: {
|
||||||
const { size = 8, multi } = props
|
className?: string
|
||||||
|
size?: number
|
||||||
|
multi?: boolean
|
||||||
|
}) {
|
||||||
|
const { className, size = 8, multi } = props
|
||||||
const insize = size - 3
|
const insize = size - 3
|
||||||
const Icon = multi ? UsersIcon : UserIcon
|
const Icon = multi ? UsersIcon : UserIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
|
className={clsx(
|
||||||
|
`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -484,6 +484,8 @@ function LimitOrderPanel(props: {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setWasSubmitted(true)
|
setWasSubmitted(true)
|
||||||
setBetAmount(undefined)
|
setBetAmount(undefined)
|
||||||
|
setLowLimitProb(undefined)
|
||||||
|
setHighLimitProb(undefined)
|
||||||
if (onBuySuccess) onBuySuccess()
|
if (onBuySuccess) onBuySuccess()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -65,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||||
/>{' '}
|
/>{' '}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
</p>
|
</p>
|
||||||
<Content content={content || text} />
|
<Content content={content || text} smallImage />
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import algoliasearch from 'algoliasearch/lite'
|
import algoliasearch from 'algoliasearch/lite'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
import { User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
QuerySortOptions,
|
QuerySortOptions,
|
||||||
Sort,
|
Sort,
|
||||||
|
@ -14,7 +15,6 @@ import {
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
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 { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { track, 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'
|
||||||
|
@ -49,6 +49,7 @@ export const DEFAULT_SORT = 'score'
|
||||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
export function ContractSearch(props: {
|
export function ContractSearch(props: {
|
||||||
|
user: User | null | undefined
|
||||||
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
|
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
|
||||||
additionalFilter?: {
|
additionalFilter?: {
|
||||||
creatorId?: string
|
creatorId?: string
|
||||||
|
@ -68,6 +69,7 @@ export function ContractSearch(props: {
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
user,
|
||||||
querySortOptions,
|
querySortOptions,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
|
@ -79,7 +81,6 @@ export function ContractSearch(props: {
|
||||||
headerClassName,
|
headerClassName,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
|
||||||
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||||
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
||||||
)
|
)
|
||||||
|
@ -234,7 +235,7 @@ export function ContractSearch(props: {
|
||||||
if (newFilter === filter) return
|
if (newFilter === filter) return
|
||||||
setFilter(newFilter)
|
setFilter(newFilter)
|
||||||
setPage(0)
|
setPage(0)
|
||||||
trackCallback('select search filter', { filter: newFilter })
|
track('select search filter', { filter: newFilter })
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectSort = (newSort: Sort) => {
|
const selectSort = (newSort: Sort) => {
|
||||||
|
@ -242,7 +243,7 @@ export function ContractSearch(props: {
|
||||||
|
|
||||||
setPage(0)
|
setPage(0)
|
||||||
setSort(newSort)
|
setSort(newSort)
|
||||||
track('select sort', { sort: newSort })
|
track('select search sort', { sort: newSort })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
|
@ -267,6 +268,7 @@ export function ContractSearch(props: {
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => updateQuery(e.target.value)}
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
|
onBlur={trackCallback('search', { query })}
|
||||||
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
/>
|
/>
|
||||||
|
@ -347,7 +349,6 @@ export function ContractSearch(props: {
|
||||||
<ContractsGrid
|
<ContractsGrid
|
||||||
contracts={hitsByPage[0] === undefined ? undefined : contracts}
|
contracts={hitsByPage[0] === undefined ? undefined : contracts}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
hasMore={true}
|
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
overrideGridClassName={overrideGridClassName}
|
overrideGridClassName={overrideGridClassName}
|
||||||
|
|
|
@ -5,10 +5,10 @@ import { SiteLink } from '../site-link'
|
||||||
import { ContractCard } from './contract-card'
|
import { ContractCard } from './contract-card'
|
||||||
import { ShowTime } from './contract-details'
|
import { ShowTime } from './contract-details'
|
||||||
import { ContractSearch } from '../contract-search'
|
import { ContractSearch } from '../contract-search'
|
||||||
import { useIsVisible } from 'web/hooks/use-is-visible'
|
import { useCallback } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { LoadingIndicator } from '../loading-indicator'
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
|
import { VisibilityObserver } from '../visibility-observer'
|
||||||
|
|
||||||
export type ContractHighlightOptions = {
|
export type ContractHighlightOptions = {
|
||||||
contractIds?: string[]
|
contractIds?: string[]
|
||||||
|
@ -17,8 +17,7 @@ export type ContractHighlightOptions = {
|
||||||
|
|
||||||
export function ContractsGrid(props: {
|
export function ContractsGrid(props: {
|
||||||
contracts: Contract[] | undefined
|
contracts: Contract[] | undefined
|
||||||
loadMore: () => void
|
loadMore?: () => void
|
||||||
hasMore: boolean
|
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
overrideGridClassName?: string
|
overrideGridClassName?: string
|
||||||
|
@ -31,7 +30,6 @@ export function ContractsGrid(props: {
|
||||||
const {
|
const {
|
||||||
contracts,
|
contracts,
|
||||||
showTime,
|
showTime,
|
||||||
hasMore,
|
|
||||||
loadMore,
|
loadMore,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
overrideGridClassName,
|
||||||
|
@ -39,16 +37,15 @@ export function ContractsGrid(props: {
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
} = props
|
} = props
|
||||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||||
|
|
||||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
const onVisibilityUpdated = useCallback(
|
||||||
const isBottomVisible = useIsVisible(elem)
|
(visible) => {
|
||||||
|
if (visible && loadMore) {
|
||||||
useEffect(() => {
|
|
||||||
if (isBottomVisible && hasMore) {
|
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, [isBottomVisible, hasMore, loadMore])
|
},
|
||||||
|
[loadMore]
|
||||||
|
)
|
||||||
|
|
||||||
if (contracts === undefined) {
|
if (contracts === undefined) {
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
|
@ -92,16 +89,23 @@ export function ContractsGrid(props: {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={setElem} className="relative -top-96 h-1" />
|
<VisibilityObserver
|
||||||
|
onVisibilityUpdated={onVisibilityUpdated}
|
||||||
|
className="relative -top-96 h-1"
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreatorContractsList(props: { creator: User }) {
|
export function CreatorContractsList(props: {
|
||||||
const { creator } = props
|
user: User | null | undefined
|
||||||
|
creator: User
|
||||||
|
}) {
|
||||||
|
const { user, creator } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
user={user}
|
||||||
querySortOptions={{
|
querySortOptions={{
|
||||||
defaultSort: 'newest',
|
defaultSort: 'newest',
|
||||||
defaultFilter: 'all',
|
defaultFilter: 'all',
|
||||||
|
|
|
@ -319,7 +319,7 @@ function getProb(contract: Contract) {
|
||||||
? getBinaryProb(contract)
|
? getBinaryProb(contract)
|
||||||
: outcomeType === 'PSEUDO_NUMERIC'
|
: outcomeType === 'PSEUDO_NUMERIC'
|
||||||
? getProbability(contract)
|
? getProbability(contract)
|
||||||
: outcomeType === 'FREE_RESPONSE'
|
: outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE'
|
||||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||||
: outcomeType === 'NUMERIC'
|
: outcomeType === 'NUMERIC'
|
||||||
? getNumericScale(contract)
|
? getNumericScale(contract)
|
||||||
|
|
|
@ -14,7 +14,9 @@ import { Button } from '../button'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { User } from 'common/user'
|
import { REFERRAL_AMOUNT, User } from 'common/user'
|
||||||
|
import { SiteLink } from '../site-link'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
export function ShareModal(props: {
|
export function ShareModal(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -26,36 +28,50 @@ export function ShareModal(props: {
|
||||||
|
|
||||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||||
|
|
||||||
const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||||
user?.username && contract.creatorUsername !== user?.username
|
user?.username && contract.creatorUsername !== user?.username
|
||||||
? '?referrer=' + user?.username
|
? '?referrer=' + user?.username
|
||||||
: ''
|
: ''
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen} size="md">
|
||||||
<Col className="gap-4 rounded bg-white p-4">
|
<Col className="gap-4 rounded bg-white p-4">
|
||||||
<Title className="!mt-0 mb-2" text="Share this market" />
|
<Title className="!mt-0 !mb-2" text="Share this market" />
|
||||||
|
<p>
|
||||||
|
Earn{' '}
|
||||||
|
<SiteLink href="/referrals">
|
||||||
|
{formatMoney(REFERRAL_AMOUNT)} referral bonus
|
||||||
|
</SiteLink>{' '}
|
||||||
|
if a new user signs up using the link!
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="2xl"
|
size="2xl"
|
||||||
color="gradient"
|
color="gradient"
|
||||||
className={'mb-2 flex max-w-xs self-center'}
|
className={'mb-2 flex max-w-xs self-center'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(copyPayload)
|
if (window.navigator.share) {
|
||||||
track('copy share link')
|
window.navigator.share({
|
||||||
|
url: shareUrl,
|
||||||
|
title: contract.question,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
copyToClipboard(shareUrl)
|
||||||
toast.success('Link copied!', {
|
toast.success('Link copied!', {
|
||||||
icon: linkIcon,
|
icon: linkIcon,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
track('copy share link')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{linkIcon} Copy link
|
{linkIcon} Copy link
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Row className="justify-start gap-4 self-center">
|
<Row className="z-0 justify-start gap-4 self-center">
|
||||||
<TweetButton
|
<TweetButton
|
||||||
className="self-start"
|
className="self-start"
|
||||||
tweetText={getTweetText(contract)}
|
tweetText={getTweetText(contract, shareUrl)}
|
||||||
/>
|
/>
|
||||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||||
<DuplicateContractButton contract={contract} />
|
<DuplicateContractButton contract={contract} />
|
||||||
|
@ -65,13 +81,9 @@ export function ShareModal(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTweetText = (contract: Contract) => {
|
const getTweetText = (contract: Contract, url: string) => {
|
||||||
const { question, resolution } = contract
|
const { question, resolution } = contract
|
||||||
|
|
||||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||||
|
|
||||||
const timeParam = `${Date.now()}`.substring(7)
|
|
||||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
|
||||||
|
|
||||||
return `${question}\n\n${url}${tweetDescription}`
|
return `${question}\n\n${url}${tweetDescription}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
FloatingMenu,
|
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
Editor,
|
Editor,
|
||||||
|
@ -11,13 +10,11 @@ import {
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
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 { Mention } from '@tiptap/extension-mention'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect, useState } 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 { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import { useUsers } from 'web/hooks/use-users'
|
import { useUsers } from 'web/hooks/use-users'
|
||||||
|
@ -37,8 +34,20 @@ import { Spacer } from './layout/spacer'
|
||||||
import { MarketModal } from './editor/market-modal'
|
import { MarketModal } from './editor/market-modal'
|
||||||
import { insertContent } from './editor/utils'
|
import { insertContent } from './editor/utils'
|
||||||
|
|
||||||
|
const DisplayImage = Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'max-h-60',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DisplayLink = Link.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const proseClass = clsx(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
'prose prose-p:my-0 prose-ul:my-0 prose-ol: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'
|
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -70,15 +79,11 @@ export function useTextEditor(props: {
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
emptyEditorClass:
|
emptyEditorClass:
|
||||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({ limit: max }),
|
CharacterCount.configure({ limit: max }),
|
||||||
Image,
|
simple ? DisplayImage : Image,
|
||||||
Link.configure({
|
DisplayLink,
|
||||||
HTMLAttributes: {
|
|
||||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
DisplayMention.configure({
|
DisplayMention.configure({
|
||||||
suggestion: mentionSuggestion(users),
|
suggestion: mentionSuggestion(users),
|
||||||
}),
|
}),
|
||||||
|
@ -126,6 +131,13 @@ function isValidIframe(text: string) {
|
||||||
return /^<iframe.*<\/iframe>$/.test(text)
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidUrl(text: string) {
|
||||||
|
// Conjured by Codex, not sure if it's actually good
|
||||||
|
return /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/.test(
|
||||||
|
text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
|
@ -139,15 +151,7 @@ export function TextEditor(props: {
|
||||||
<>
|
<>
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
{editor && (
|
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
<FloatingMenu
|
|
||||||
editor={editor}
|
|
||||||
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
|
||||||
>
|
|
||||||
Type <em>*markdown*</em>
|
|
||||||
</FloatingMenu>
|
|
||||||
)}
|
|
||||||
<div className="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} />
|
||||||
{/* Toolbar, with buttons for images and embeds */}
|
{/* Toolbar, with buttons for images and embeds */}
|
||||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||||
|
@ -190,7 +194,14 @@ export function TextEditor(props: {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto" />
|
{/* Spacer that also focuses editor on click */}
|
||||||
|
<div
|
||||||
|
className="grow cursor-text self-stretch"
|
||||||
|
onMouseDown={() =>
|
||||||
|
editor?.chain().focus('end').createParagraphNear().run()
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -209,8 +220,9 @@ function IframeModal(props: {
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const { editor, open, setOpen } = props
|
const { editor, open, setOpen } = props
|
||||||
const [embedCode, setEmbedCode] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const valid = isValidIframe(embedCode)
|
const valid = isValidIframe(input) || isValidUrl(input)
|
||||||
|
const embedCode = isValidIframe(input) ? input : `<iframe src="${input}" />`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
@ -227,8 +239,8 @@ function IframeModal(props: {
|
||||||
id="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"
|
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>'
|
placeholder='e.g. <iframe src="..."></iframe>'
|
||||||
value={embedCode}
|
value={input}
|
||||||
onChange={(e) => setEmbedCode(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Preview the embed if it's valid */}
|
{/* Preview the embed if it's valid */}
|
||||||
|
@ -240,7 +252,7 @@ function IframeModal(props: {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editor && valid) {
|
if (editor && valid) {
|
||||||
insertContent(editor, embedCode)
|
insertContent(editor, embedCode)
|
||||||
setEmbedCode('')
|
setInput('')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -250,7 +262,7 @@ function IframeModal(props: {
|
||||||
<Button
|
<Button
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEmbedCode('')
|
setInput('')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -280,14 +292,19 @@ const useUploadMutation = (editor: Editor | null) =>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function RichContent(props: { content: JSONContent | string }) {
|
function RichContent(props: {
|
||||||
const { content } = props
|
content: JSONContent | string
|
||||||
|
smallImage?: boolean
|
||||||
|
}) {
|
||||||
|
const { content, smallImage } = props
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: { attributes: { class: proseClass } },
|
editorProps: { attributes: { class: proseClass } },
|
||||||
extensions: [
|
extensions: [
|
||||||
// replace tiptap's Mention with ours, to add style and link
|
StarterKit,
|
||||||
...exhibitExts.filter((ex) => ex.name !== Mention.name),
|
smallImage ? DisplayImage : Image,
|
||||||
|
DisplayLink,
|
||||||
DisplayMention,
|
DisplayMention,
|
||||||
|
Iframe,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editable: false,
|
editable: false,
|
||||||
|
@ -298,13 +315,16 @@ function RichContent(props: { content: JSONContent | string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// backwards compatibility: we used to store content as strings
|
// backwards compatibility: we used to store content as strings
|
||||||
export function Content(props: { content: JSONContent | string }) {
|
export function Content(props: {
|
||||||
|
content: JSONContent | string
|
||||||
|
smallImage?: boolean
|
||||||
|
}) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
return typeof content === 'string' ? (
|
return typeof content === 'string' ? (
|
||||||
<div className="whitespace-pre-line font-light leading-relaxed">
|
<div className="whitespace-pre-line font-light leading-relaxed">
|
||||||
<Linkify text={content} />
|
<Linkify text={content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RichContent content={content} />
|
<RichContent {...props} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,8 +36,7 @@ export function FeedBet(props: {
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Row className={'flex w-full items-center gap-2 pt-3'}>
|
||||||
<Row className={'flex w-full gap-2 pt-3'}>
|
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar
|
<Avatar
|
||||||
className={clsx(smallAvatar && 'ml-1')}
|
className={clsx(smallAvatar && 'ml-1')}
|
||||||
|
@ -53,21 +52,17 @@ export function FeedBet(props: {
|
||||||
username={bettor.username}
|
username={bettor.username}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative px-1">
|
<EmptyAvatar className="mx-1" />
|
||||||
<EmptyAvatar />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
|
||||||
<BetStatusText
|
<BetStatusText
|
||||||
bet={bet}
|
bet={bet}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isSelf={isSelf}
|
isSelf={isSelf}
|
||||||
bettor={bettor}
|
bettor={bettor}
|
||||||
hideOutcome={hideOutcome}
|
hideOutcome={hideOutcome}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,8 +72,9 @@ export function BetStatusText(props: {
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
bettor?: User
|
bettor?: User
|
||||||
hideOutcome?: boolean
|
hideOutcome?: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { bet, contract, bettor, isSelf, hideOutcome } = props
|
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||||
|
@ -123,7 +119,7 @@ export function BetStatusText(props: {
|
||||||
: formatPercent(bet.limitProb ?? bet.probAfter)
|
: formatPercent(bet.limitProb ?? bet.probAfter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-500">
|
<div className={clsx('text-sm text-gray-500', className)}>
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
<UserLink name={bettor.name} username={bettor.username} />
|
<UserLink name={bettor.name} username={bettor.username} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -254,7 +254,7 @@ export function FeedComment(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[15px] text-gray-700">
|
<div className="mt-2 text-[15px] text-gray-700">
|
||||||
<Content content={content || text} />
|
<Content content={content || text} smallImage />
|
||||||
</div>
|
</div>
|
||||||
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
||||||
<Tipper comment={comment} tips={tips ?? {}} />
|
<Tipper comment={comment} tips={tips ?? {}} />
|
||||||
|
@ -394,8 +394,8 @@ export function CommentInput(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={'min-w-0 flex-1'}>
|
<div className={'min-w-0 flex-1'}>
|
||||||
<div className="pl-0.5 text-sm text-gray-500">
|
<div className="pl-0.5 text-sm">
|
||||||
<div className={'mb-1'}>
|
<div className="mb-1 text-gray-500">
|
||||||
{mostRecentCommentableBet && (
|
{mostRecentCommentableBet && (
|
||||||
<BetStatusText
|
<BetStatusText
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// From https://tailwindui.com/components/application-ui/lists/feeds
|
// From https://tailwindui.com/components/application-ui/lists/feeds
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
@ -22,7 +22,6 @@ import { UserLink } from '../user-page'
|
||||||
import BetRow from '../bet-row'
|
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 { useUser } from 'web/hooks/use-user'
|
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'
|
||||||
|
@ -50,11 +49,8 @@ export function FeedItems(props: {
|
||||||
const { contract, items, className, betRowClassName, user } = props
|
const { contract, items, className, betRowClassName, user } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
|
||||||
useSaveSeenContract(elem, contract)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flow-root', className)} ref={setElem}>
|
<div className={clsx('flow-root', className)}>
|
||||||
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
||||||
{items.map((item, activityItemIdx) => (
|
{items.map((item, activityItemIdx) => (
|
||||||
<div key={item.id} className={'relative pb-4'}>
|
<div key={item.id} className={'relative pb-4'}>
|
||||||
|
|
|
@ -338,7 +338,11 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
</Row>
|
</Row>
|
||||||
<div className="mt-2 text-black">
|
<div className="mt-2 text-black">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<Content content={comment.content || comment.text} />
|
<Content
|
||||||
|
key={comment.id}
|
||||||
|
content={comment.content || comment.text}
|
||||||
|
smallImage
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Row>
|
<Row>
|
||||||
|
|
|
@ -59,11 +59,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
||||||
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
|
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
|
||||||
Trending markets
|
Trending markets
|
||||||
</Row>
|
</Row>
|
||||||
<ContractsGrid
|
<ContractsGrid contracts={hotContracts?.slice(0, 10) || []} />
|
||||||
contracts={hotContracts?.slice(0, 10) || []}
|
|
||||||
loadMore={() => {}}
|
|
||||||
hasMore={false}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { useUnseenPreferredNotifications } from 'web/hooks/use-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'
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
|
import { buildArray } from 'common/util/array'
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
// log out, and then reload the page, in case SSR wants to boot them out
|
// log out, and then reload the page, in case SSR wants to boot them out
|
||||||
|
@ -61,42 +62,31 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (CHALLENGES_ENABLED)
|
return buildArray(
|
||||||
return [
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
{ name: 'Challenges', href: '/challenges' },
|
[
|
||||||
{ name: 'Charity', href: '/charity' },
|
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
|
||||||
]
|
|
||||||
else
|
|
||||||
return [
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
|
},
|
||||||
{ 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' },
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||||
]
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CHALLENGES_ENABLED)
|
return buildArray(
|
||||||
return [
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
{ name: 'Challenges', href: '/challenges' },
|
[
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
|
||||||
{ name: 'Send M$', href: '/links' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
|
||||||
{
|
|
||||||
name: 'Sign out',
|
|
||||||
href: '#',
|
|
||||||
onClick: logout,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
else
|
|
||||||
return [
|
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Send M$', href: '/links' },
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
|
},
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
{
|
{
|
||||||
|
@ -105,6 +95,7 @@ function getMoreNavigation(user?: User | null) {
|
||||||
onClick: logout,
|
onClick: logout,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
|
@ -141,29 +132,27 @@ const signedInMobileNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function getMoreMobileNav() {
|
function getMoreMobileNav() {
|
||||||
return [
|
const signOut = {
|
||||||
...(IS_PRIVATE_MANIFOLD
|
|
||||||
? []
|
|
||||||
: CHALLENGES_ENABLED
|
|
||||||
? [
|
|
||||||
{ name: 'Challenges', href: '/challenges' },
|
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
|
||||||
{ name: 'Send M$', href: '/links' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
|
||||||
{ name: 'Charity', href: '/charity' },
|
|
||||||
{ name: 'Send M$', href: '/links' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
onClick: logout,
|
onClick: logout,
|
||||||
|
}
|
||||||
|
if (IS_PRIVATE_MANIFOLD) return [signOut]
|
||||||
|
|
||||||
|
return buildArray<Item>(
|
||||||
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
|
[
|
||||||
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
},
|
},
|
||||||
]
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
],
|
||||||
|
signOut
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
|
@ -328,8 +317,7 @@ function GroupsList(props: {
|
||||||
|
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const remainingHeight =
|
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||||
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
|
|
||||||
|
|
||||||
const notifIsForThisItem = useMemo(
|
const notifIsForThisItem = useMemo(
|
||||||
() => (itemHref: string) =>
|
() => (itemHref: string) =>
|
||||||
|
|
|
@ -61,7 +61,8 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||||
min: Math.min(...points.map((p) => p.y)),
|
min: Math.min(...points.map((p) => p.y)),
|
||||||
}}
|
}}
|
||||||
gridYValues={numYTickValues}
|
gridYValues={numYTickValues}
|
||||||
curve="monotoneX"
|
curve="stepAfter"
|
||||||
|
enablePoints={false}
|
||||||
colors={{ datum: 'color' }}
|
colors={{ datum: 'color' }}
|
||||||
axisBottom={{
|
axisBottom={{
|
||||||
tickValues: numXTickValues,
|
tickValues: numXTickValues,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ShareIcon } from '@heroicons/react/outline'
|
import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
|
@ -40,7 +40,7 @@ export function ShareIconButton(props: {
|
||||||
setTimeout(() => setShowToast(false), 2000)
|
setTimeout(() => setShowToast(false), 2000)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ShareIcon
|
<LinkIcon
|
||||||
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
|
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
|
|
||||||
// declare debounced function only on first render
|
// declare debounced function only on first render
|
||||||
const [saveTip] = useState(() =>
|
const [saveTip] = useState(() =>
|
||||||
debounce(async (user: User, change: number) => {
|
debounce(async (user: User, comment: Comment, change: number) => {
|
||||||
if (change === 0) {
|
if (change === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -71,30 +71,24 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
// instant save on unrender
|
// instant save on unrender
|
||||||
useEffect(() => () => void saveTip.flush(), [saveTip])
|
useEffect(() => () => void saveTip.flush(), [saveTip])
|
||||||
|
|
||||||
const changeTip = (tip: number) => {
|
const addTip = (delta: number) => {
|
||||||
setLocalTip(tip)
|
setLocalTip(localTip + delta)
|
||||||
me && saveTip(me, tip - savedTip)
|
me && saveTip(me, comment, localTip - savedTip + delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canDown = me && localTip > savedTip
|
||||||
|
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-0.5">
|
<Row className="items-center gap-0.5">
|
||||||
<DownTip
|
<DownTip onClick={canDown ? () => addTip(-5) : undefined} />
|
||||||
value={localTip}
|
|
||||||
onChange={changeTip}
|
|
||||||
disabled={!me || localTip <= savedTip}
|
|
||||||
/>
|
|
||||||
<span className="font-bold">{Math.floor(total)}</span>
|
<span className="font-bold">{Math.floor(total)}</span>
|
||||||
<UpTip
|
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} />
|
||||||
value={localTip}
|
|
||||||
onChange={changeTip}
|
|
||||||
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
|
|
||||||
/>
|
|
||||||
{localTip === 0 ? (
|
{localTip === 0 ? (
|
||||||
''
|
''
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'font-semibold',
|
'ml-1 font-semibold',
|
||||||
localTip > 0 ? 'text-primary' : 'text-red-400'
|
localTip > 0 ? 'text-primary' : 'text-red-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -105,21 +99,17 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownTip(prop: {
|
function DownTip(props: { onClick?: () => void }) {
|
||||||
value: number
|
const { onClick } = props
|
||||||
onChange: (tip: number) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const { onChange, value, disabled } = prop
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="tooltip-bottom h-6 w-6"
|
||||||
text={!disabled && `-${formatMoney(5)}`}
|
text={onClick && `-${formatMoney(5)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
className="hover:text-red-600 disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={!onClick}
|
||||||
onClick={() => onChange(value - 5)}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-6 w-6" />
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -127,30 +117,20 @@ function DownTip(prop: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpTip(prop: {
|
function UpTip(props: { onClick?: () => void; value: number }) {
|
||||||
value: number
|
const { onClick, value } = props
|
||||||
onChange: (tip: number) => void
|
const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const { onChange, value, disabled } = prop
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="tooltip-bottom h-6 w-6"
|
||||||
text={!disabled && `Tip ${formatMoney(5)}`}
|
text={onClick && `Tip ${formatMoney(5)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
className="hover:text-primary disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={!onClick}
|
||||||
onClick={() => onChange(value + 5)}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{value >= 10 ? (
|
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
|
||||||
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
|
||||||
) : value > 0 ? (
|
|
||||||
<ChevronRightIcon className="text-primary h-6 w-6" />
|
|
||||||
) : (
|
|
||||||
<ChevronRightIcon className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|
|
@ -280,8 +280,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Refer a friend and earn {formatMoney(500)} when they sign up! You
|
<SiteLink href="/referrals">
|
||||||
have <ReferralsButton user={user} currentUser={currentUser} />
|
Refer a friend and earn {formatMoney(500)} when they sign up!
|
||||||
|
</SiteLink>{' '}
|
||||||
|
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||||
</span>
|
</span>
|
||||||
<ShareIconButton
|
<ShareIconButton
|
||||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||||
|
@ -300,7 +302,9 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: <CreatorContractsList creator={user} />,
|
content: (
|
||||||
|
<CreatorContractsList user={currentUser} creator={user} />
|
||||||
|
),
|
||||||
tabIcon: (
|
tabIcon: (
|
||||||
<span className="px-0.5 font-bold">
|
<span className="px-0.5 font-bold">
|
||||||
{usersContracts.length}
|
{usersContracts.length}
|
||||||
|
|
24
web/components/visibility-observer.tsx
Normal file
24
web/components/visibility-observer.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useEvent } from '../hooks/use-event'
|
||||||
|
|
||||||
|
export function VisibilityObserver(props: {
|
||||||
|
className?: string
|
||||||
|
onVisibilityUpdated: (visible: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { className } = props
|
||||||
|
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||||
|
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasIOSupport = !!window.IntersectionObserver
|
||||||
|
if (!hasIOSupport || !elem) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
onVisibilityUpdated(entry.isIntersecting)
|
||||||
|
}, {})
|
||||||
|
observer.observe(elem)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [elem, onVisibilityUpdated])
|
||||||
|
|
||||||
|
return <div ref={setElem} className={className}></div>
|
||||||
|
}
|
|
@ -1,28 +0,0 @@
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export function useIsVisible(element: HTMLElement | null) {
|
|
||||||
return !!useIntersectionObserver(element)?.isIntersecting
|
|
||||||
}
|
|
||||||
|
|
||||||
function useIntersectionObserver(
|
|
||||||
elem: HTMLElement | null
|
|
||||||
): IntersectionObserverEntry | undefined {
|
|
||||||
const [entry, setEntry] = useState<IntersectionObserverEntry>()
|
|
||||||
|
|
||||||
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
|
|
||||||
setEntry(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasIOSupport = !!window.IntersectionObserver
|
|
||||||
|
|
||||||
if (!hasIOSupport || !elem) return
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(updateEntry, {})
|
|
||||||
observer.observe(elem)
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [elem])
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { mapValues } from 'lodash'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { trackView } from 'web/lib/firebase/tracking'
|
|
||||||
import { useIsVisible } from './use-is-visible'
|
|
||||||
import { useUser } from './use-user'
|
|
||||||
|
|
||||||
export const useSeenContracts = () => {
|
|
||||||
const [seenContracts, setSeenContracts] = useState<{
|
|
||||||
[contractId: string]: number
|
|
||||||
}>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSeenContracts(getSeenContracts())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return seenContracts
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSaveSeenContract = (
|
|
||||||
elem: HTMLElement | null,
|
|
||||||
contract: Contract
|
|
||||||
) => {
|
|
||||||
const isVisible = useIsVisible(elem)
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible && user) {
|
|
||||||
const newSeenContracts = {
|
|
||||||
...getSeenContracts(),
|
|
||||||
[contract.id]: Date.now(),
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, JSON.stringify(newSeenContracts))
|
|
||||||
|
|
||||||
trackView(user.id, contract.id)
|
|
||||||
}
|
|
||||||
}, [isVisible, user, contract])
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = 'feed-seen-contracts'
|
|
||||||
|
|
||||||
const getSeenContracts = () => {
|
|
||||||
return mapValues(
|
|
||||||
JSON.parse(localStorage.getItem(key) ?? '{}'),
|
|
||||||
(time) => +time
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -3,6 +3,8 @@ import imageCompression from 'browser-image-compression'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { storage } from './init'
|
import { storage } from './init'
|
||||||
|
|
||||||
|
const ONE_YEAR_SECS = 60 * 60 * 24 * 365
|
||||||
|
|
||||||
export const uploadImage = async (
|
export const uploadImage = async (
|
||||||
username: string,
|
username: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
@ -24,7 +26,9 @@ export const uploadImage = async (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadTask = uploadBytesResumable(storageRef, file)
|
const uploadTask = uploadBytesResumable(storageRef, file, {
|
||||||
|
cacheControl: `public, max-age=${ONE_YEAR_SECS}`,
|
||||||
|
})
|
||||||
|
|
||||||
let resolvePromise: (url: string) => void
|
let resolvePromise: (url: string) => void
|
||||||
let rejectPromise: (reason?: any) => void
|
let rejectPromise: (reason?: any) => void
|
||||||
|
|
|
@ -54,7 +54,7 @@ export async function getUser(userId: string) {
|
||||||
|
|
||||||
export async function getPrivateUser(userId: string) {
|
export async function getPrivateUser(userId: string) {
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
return (await getDoc(doc(users, userId))).data()!
|
return (await getDoc(doc(privateUsers, userId))).data()!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByUsername(username: string) {
|
export async function getUserByUsername(username: string) {
|
||||||
|
|
|
@ -92,6 +92,7 @@ export default function ContractPage(props: {
|
||||||
slug: '',
|
slug: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
const inIframe = useIsIframe()
|
const inIframe = useIsIframe()
|
||||||
if (inIframe) {
|
if (inIframe) {
|
||||||
return <ContractEmbedPage {...props} />
|
return <ContractEmbedPage {...props} />
|
||||||
|
@ -103,46 +104,15 @@ export default function ContractPage(props: {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ContractPageContent {...{ ...props, contract }} />
|
return <ContractPageContent {...{ ...props, contract, user }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractPageContent(
|
export function ContractPageSidebar(props: {
|
||||||
props: Parameters<typeof ContractPage>[0] & { contract: Contract }
|
user: User | null | undefined
|
||||||
) {
|
contract: Contract
|
||||||
const { backToHome, comments } = props
|
}) {
|
||||||
|
const { contract, user } = props
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const { creatorId, isResolved, outcomeType } = contract
|
||||||
|
|
||||||
useTracking('view market', {
|
|
||||||
slug: contract.slug,
|
|
||||||
contractId: contract.id,
|
|
||||||
creatorId: contract.creatorId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const bets = useBets(contract.id) ?? props.bets
|
|
||||||
const liquidityProvisions =
|
|
||||||
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
|
|
||||||
// Sort for now to see if bug is fixed.
|
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
|
||||||
|
|
||||||
const tips = useTipTxns({ contractId: contract.id })
|
|
||||||
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
const { width, height } = useWindowSize()
|
|
||||||
|
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const shouldSeeConfetti = !!(
|
|
||||||
user &&
|
|
||||||
contract.creatorId === user.id &&
|
|
||||||
Date.now() - contract.createdTime < 10 * 1000
|
|
||||||
)
|
|
||||||
setShowConfetti(shouldSeeConfetti)
|
|
||||||
}, [contract, user])
|
|
||||||
|
|
||||||
const { creatorId, isResolved, question, outcomeType } = contract
|
|
||||||
|
|
||||||
const isCreator = user?.id === creatorId
|
const isCreator = user?.id === creatorId
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
@ -153,14 +123,7 @@ export function ContractPageContent(
|
||||||
const hasSidePanel =
|
const hasSidePanel =
|
||||||
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
|
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
|
||||||
|
|
||||||
const ogCardProps = getOpenGraphProps(contract)
|
return hasSidePanel ? (
|
||||||
|
|
||||||
useSaveReferral(user, {
|
|
||||||
defaultReferrerUsername: contract.creatorUsername,
|
|
||||||
contractId: contract.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const rightSidebar = hasSidePanel ? (
|
|
||||||
<Col className="gap-4">
|
<Col className="gap-4">
|
||||||
{allowTrade &&
|
{allowTrade &&
|
||||||
(isNumeric ? (
|
(isNumeric ? (
|
||||||
|
@ -179,7 +142,57 @@ export function ContractPageContent(
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
) : null
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractPageContent(
|
||||||
|
props: Parameters<typeof ContractPage>[0] & {
|
||||||
|
contract: Contract
|
||||||
|
user?: User | null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { backToHome, comments, user } = props
|
||||||
|
|
||||||
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
|
|
||||||
|
useTracking('view market', {
|
||||||
|
slug: contract.slug,
|
||||||
|
contractId: contract.id,
|
||||||
|
creatorId: contract.creatorId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bets = useBets(contract.id) ?? props.bets
|
||||||
|
const liquidityProvisions =
|
||||||
|
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
|
||||||
|
// Sort for now to see if bug is fixed.
|
||||||
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
|
||||||
|
const tips = useTipTxns({ contractId: contract.id })
|
||||||
|
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldSeeConfetti = !!(
|
||||||
|
user &&
|
||||||
|
contract.creatorId === user.id &&
|
||||||
|
Date.now() - contract.createdTime < 10 * 1000
|
||||||
|
)
|
||||||
|
setShowConfetti(shouldSeeConfetti)
|
||||||
|
}, [contract, user])
|
||||||
|
|
||||||
|
const { isResolved, question, outcomeType } = contract
|
||||||
|
|
||||||
|
const allowTrade = tradingAllowed(contract)
|
||||||
|
|
||||||
|
const ogCardProps = getOpenGraphProps(contract)
|
||||||
|
|
||||||
|
useSaveReferral(user, {
|
||||||
|
defaultReferrerUsername: contract.creatorUsername,
|
||||||
|
contractId: contract.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightSidebar = <ContractPageSidebar user={user} contract={contract} />
|
||||||
return (
|
return (
|
||||||
<Page rightSidebar={rightSidebar}>
|
<Page rightSidebar={rightSidebar}>
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
|
@ -216,7 +229,7 @@ export function ContractPageContent(
|
||||||
bets={bets.filter((b) => !b.challengeSlug)}
|
bets={bets.filter((b) => !b.challengeSlug)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isNumeric && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<AlertBox
|
<AlertBox
|
||||||
title="Warning"
|
title="Warning"
|
||||||
text="Distributional numeric markets were introduced as an experimental feature and are now deprecated."
|
text="Distributional numeric markets were introduced as an experimental feature and are now deprecated."
|
||||||
|
@ -232,7 +245,7 @@ export function ContractPageContent(
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNumeric && allowTrade && (
|
{outcomeType === 'NUMERIC' && allowTrade && (
|
||||||
<NumericBetPanel className="xl:hidden" contract={contract} />
|
<NumericBetPanel className="xl:hidden" contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -108,12 +108,7 @@ export default function ContractSearchFirestore(props: {
|
||||||
<option value="close-date">Closing soon</option>
|
<option value="close-date">Closing soon</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<ContractsGrid
|
<ContractsGrid contracts={matches} showTime={showTime} />
|
||||||
contracts={matches}
|
|
||||||
loadMore={() => {}}
|
|
||||||
hasMore={false}
|
|
||||||
showTime={showTime}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
import { scoreCreators, scoreTraders } from 'common/scoring'
|
||||||
|
@ -30,7 +30,6 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { GroupChatInBubble } from 'web/components/groups/group-chat'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
||||||
|
@ -51,6 +50,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
|
import { GroupChat } from 'web/components/groups/group-chat'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
|
@ -157,16 +157,12 @@ export default function GroupPage(props: {
|
||||||
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrerUsername: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
groupId: group?.id,
|
groupId: group?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatDisabled = !group || group.chatDisabled
|
|
||||||
const showChatBubble = !chatDisabled
|
|
||||||
|
|
||||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
|
@ -201,6 +197,7 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const questionsTab = (
|
const questionsTab = (
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
user={user}
|
||||||
querySortOptions={{
|
querySortOptions={{
|
||||||
shouldLoadFromStorage: true,
|
shouldLoadFromStorage: true,
|
||||||
defaultSort: getSavedSort() ?? 'newest',
|
defaultSort: getSavedSort() ?? 'newest',
|
||||||
|
@ -210,12 +207,21 @@ export default function GroupPage(props: {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const chatTab = (
|
||||||
|
<GroupChat messages={messages} group={group} user={user} tips={tips} />
|
||||||
|
)
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: questionsTab,
|
content: questionsTab,
|
||||||
href: groupPath(group.slug, 'markets'),
|
href: groupPath(group.slug, 'markets'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Chat',
|
||||||
|
content: chatTab,
|
||||||
|
href: groupPath(group.slug, 'chat'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Leaderboards',
|
title: 'Leaderboards',
|
||||||
content: leaderboard,
|
content: leaderboard,
|
||||||
|
@ -228,7 +234,9 @@ export default function GroupPage(props: {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
|
const tabIndex = tabs
|
||||||
|
.map((t) => t.title.toLowerCase())
|
||||||
|
.indexOf(page ?? 'markets')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -264,15 +272,6 @@ export default function GroupPage(props: {
|
||||||
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
{showChatBubble && (
|
|
||||||
<GroupChatInBubble
|
|
||||||
group={group}
|
|
||||||
user={user}
|
|
||||||
privateUser={privateUser}
|
|
||||||
tips={tips}
|
|
||||||
messages={messages}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -614,6 +613,7 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
|
|
||||||
<div className={'overflow-y-scroll sm:px-8'}>
|
<div className={'overflow-y-scroll sm:px-8'}>
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
user={user}
|
||||||
hideOrderSelector={true}
|
hideOrderSelector={true}
|
||||||
onContractClick={addContractToCurrentGroup}
|
onContractClick={addContractToCurrentGroup}
|
||||||
overrideGridClassName={
|
overrideGridClassName={
|
||||||
|
|
|
@ -7,16 +7,22 @@ import { Col } from 'web/components/layout/col'
|
||||||
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
||||||
import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
|
import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
import { User } from 'common/user'
|
||||||
import { ContractPageContent } from './[username]/[contractSlug]'
|
import { ContractPageContent } from './[username]/[contractSlug]'
|
||||||
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
||||||
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||||
|
const user = await getUser(creds.user.uid)
|
||||||
|
return { props: { user } }
|
||||||
|
})
|
||||||
|
|
||||||
const Home = () => {
|
const Home = (props: { user: User }) => {
|
||||||
|
const { user } = props
|
||||||
const [contract, setContract] = useContractPage()
|
const [contract, setContract] = useContractPage()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -29,6 +35,7 @@ const Home = () => {
|
||||||
<Page suspend={!!contract}>
|
<Page suspend={!!contract}>
|
||||||
<Col className="mx-auto w-full p-2">
|
<Col className="mx-auto w-full p-2">
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
user={user}
|
||||||
querySortOptions={{
|
querySortOptions={{
|
||||||
shouldLoadFromStorage: true,
|
shouldLoadFromStorage: true,
|
||||||
defaultSort: getSavedSort() ?? DEFAULT_SORT,
|
defaultSort: getSavedSort() ?? DEFAULT_SORT,
|
||||||
|
@ -56,6 +63,7 @@ const Home = () => {
|
||||||
{contract && (
|
{contract && (
|
||||||
<ContractPageContent
|
<ContractPageContent
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
user={user}
|
||||||
username={contract.creatorUsername}
|
username={contract.creatorUsername}
|
||||||
slug={contract.slug}
|
slug={contract.slug}
|
||||||
bets={[]}
|
bets={[]}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { ManalinkCardFromView } from 'web/components/manalink-card'
|
||||||
import { Pagination } from 'web/components/pagination'
|
import { Pagination } from 'web/components/pagination'
|
||||||
import { Manalink } from 'common/manalink'
|
import { Manalink } from 'common/manalink'
|
||||||
import { REFERRAL_AMOUNT } from 'common/user'
|
import { REFERRAL_AMOUNT } from 'common/user'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
|
||||||
const LINKS_PER_PAGE = 24
|
const LINKS_PER_PAGE = 24
|
||||||
|
|
||||||
|
@ -69,9 +70,11 @@ export default function LinkPage(props: { user: User }) {
|
||||||
</Row>
|
</Row>
|
||||||
<p>
|
<p>
|
||||||
You can use manalinks to send mana (M$) to other people, even if they
|
You can use manalinks to send mana (M$) to other people, even if they
|
||||||
don't yet have a Manifold account. Manalinks are also eligible
|
don't yet have a Manifold account.{' '}
|
||||||
for the referral bonus. Invite a new user to Manifold and get M$
|
<SiteLink href="/referrals">
|
||||||
{REFERRAL_AMOUNT} if they sign up!
|
Eligible for {formatMoney(REFERRAL_AMOUNT)} referral bonus if a new
|
||||||
|
user signs up!
|
||||||
|
</SiteLink>
|
||||||
</p>
|
</p>
|
||||||
<Subtitle text="Your Manalinks" />
|
<Subtitle text="Your Manalinks" />
|
||||||
<ManalinksDisplay
|
<ManalinksDisplay
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { ContractSearch } from '../components/contract-search'
|
import { ContractSearch } from '../components/contract-search'
|
||||||
import { Page } from '../components/page'
|
import { Page } from '../components/page'
|
||||||
import { SEO } from '../components/SEO'
|
import { SEO } from '../components/SEO'
|
||||||
|
|
||||||
// TODO: Rename endpoint to "Explore"
|
// TODO: Rename endpoint to "Explore"
|
||||||
export default function Markets() {
|
export default function Markets() {
|
||||||
|
const user = useUser()
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<SEO
|
<SEO
|
||||||
|
@ -11,7 +13,7 @@ export default function Markets() {
|
||||||
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
|
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
|
||||||
url="/markets"
|
url="/markets"
|
||||||
/>
|
/>
|
||||||
<ContractSearch />
|
<ContractSearch user={user} />
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { REFERRAL_AMOUNT } from 'common/user'
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { InfoBox } from 'web/components/info-box'
|
import { InfoBox } from 'web/components/info-box'
|
||||||
|
import { QRCode } from 'web/components/qr-code'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||||
|
|
||||||
|
@ -50,6 +51,8 @@ export default function ReferralsPage() {
|
||||||
toastClassName={'-left-28 mt-1'}
|
toastClassName={'-left-28 mt-1'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QRCode url={url} className="mt-4 self-center" />
|
||||||
|
|
||||||
<InfoBox
|
<InfoBox
|
||||||
title="FYI"
|
title="FYI"
|
||||||
className="mt-4 max-w-md"
|
className="mt-4 max-w-md"
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { ContractSearch } from '../../components/contract-search'
|
import { ContractSearch } from '../../components/contract-search'
|
||||||
import { Page } from '../../components/page'
|
import { Page } from '../../components/page'
|
||||||
import { Title } from '../../components/title'
|
import { Title } from '../../components/title'
|
||||||
|
|
||||||
export default function TagPage() {
|
export default function TagPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const user = useUser()
|
||||||
const { tag } = router.query as { tag: string }
|
const { tag } = router.query as { tag: string }
|
||||||
if (!router.isReady) return <div />
|
if (!router.isReady) return <div />
|
||||||
|
|
||||||
|
@ -12,6 +14,7 @@ export default function TagPage() {
|
||||||
<Page>
|
<Page>
|
||||||
<Title text={`#${tag}`} />
|
<Title text={`#${tag}`} />
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
user={user}
|
||||||
querySortOptions={{
|
querySortOptions={{
|
||||||
defaultSort: 'newest',
|
defaultSort: 'newest',
|
||||||
defaultFilter: 'all',
|
defaultFilter: 'all',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user