Merge branch 'main' into editor-market

This commit is contained in:
Sinclair Chen 2022-08-10 15:36:25 -07:00
commit 023a404b5f
34 changed files with 414 additions and 380 deletions

View File

@ -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

View File

@ -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
}

View 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))
}

View File

@ -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>

View File

@ -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()
}) })

View File

@ -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>
) )

View File

@ -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}

View File

@ -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(() => { loadMore()
if (isBottomVisible && hasMore) { }
loadMore() },
} [loadMore]
}, [isBottomVisible, hasMore, 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',

View File

@ -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)

View File

@ -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) {
window.navigator.share({
url: shareUrl,
title: contract.question,
})
} else {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: linkIcon,
})
}
track('copy share link') track('copy share link')
toast.success('Link copied!', {
icon: linkIcon,
})
}} }}
> >
{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}`
} }

View File

@ -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} />
) )
} }

View File

@ -36,38 +36,33 @@ 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')} size={smallAvatar ? 'sm' : undefined}
size={smallAvatar ? 'sm' : undefined} avatarUrl={user.avatarUrl}
avatarUrl={user.avatarUrl} username={user.username}
username={user.username} />
/> ) : bettor ? (
) : bettor ? ( <Avatar
<Avatar className={clsx(smallAvatar && 'ml-1')}
className={clsx(smallAvatar && 'ml-1')} size={smallAvatar ? 'sm' : undefined}
size={smallAvatar ? 'sm' : undefined} avatarUrl={bettor.avatarUrl}
avatarUrl={bettor.avatarUrl} username={bettor.username}
username={bettor.username} />
/> ) : (
) : ( <EmptyAvatar className="mx-1" />
<div className="relative px-1"> )}
<EmptyAvatar /> <BetStatusText
</div> bet={bet}
)} contract={contract}
<div className={'min-w-0 flex-1 py-1.5'}> isSelf={isSelf}
<BetStatusText bettor={bettor}
bet={bet} hideOutcome={hideOutcome}
contract={contract} className="flex-1"
isSelf={isSelf} />
bettor={bettor} </Row>
hideOutcome={hideOutcome}
/>
</div>
</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} />
) : ( ) : (

View File

@ -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}

View File

@ -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'}>

View File

@ -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>

View File

@ -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}
/>
</> </>
) )
} }

View File

@ -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 name: 'Sign out',
? [] href: '#',
: CHALLENGES_ENABLED onClick: logout,
? [ }
{ name: 'Challenges', href: '/challenges' }, if (IS_PRIVATE_MANIFOLD) return [signOut]
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, return buildArray<Item>(
{ name: 'Send M$', href: '/links' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, [
] { name: 'Referrals', href: '/referrals' },
: [ {
{ name: 'Referrals', href: '/referrals' }, name: 'Salem tournament',
{ name: 'Charity', href: '/charity' }, href: 'https://salemcenter.manifold.markets/',
{ name: 'Send M$', href: '/links' }, },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Charity', href: '/charity' },
]), { name: 'Send M$', href: '/links' },
{ { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
name: 'Sign out', ],
href: '#', signOut
onClick: logout, )
},
]
} }
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) =>

View File

@ -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,

View File

@ -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"
/> />

View File

@ -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>
) )

View File

@ -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}

View 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>
}

View File

@ -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
}

View File

@ -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
)
}

View File

@ -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

View File

@ -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) {

View File

@ -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} />
)} )}

View File

@ -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>
) )
} }

View File

@ -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={

View File

@ -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={[]}

View File

@ -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&apos;t yet have a Manifold account. Manalinks are also eligible don&apos;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

View File

@ -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>
) )
} }

View File

@ -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"

View File

@ -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',