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
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 CPMM_MIN_POOL_QTY = 0.01

View File

@ -1,3 +1,19 @@
export function filterDefined<T>(array: (T | null | undefined)[]) {
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 }) {
const { size = 8, multi } = props
export function EmptyAvatar(props: {
className?: string
size?: number
multi?: boolean
}) {
const { className, size = 8, multi } = props
const insize = size - 3
const Icon = multi ? UsersIcon : UserIcon
return (
<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 />
</div>

View File

@ -484,6 +484,8 @@ function LimitOrderPanel(props: {
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
setLowLimitProb(undefined)
setHighLimitProb(undefined)
if (onBuySuccess) onBuySuccess()
})

View File

@ -65,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
/>{' '}
<RelativeTimestamp time={createdTime} />
</p>
<Content content={content || text} />
<Content content={content || text} smallImage />
</div>
</Row>
)

View File

@ -2,6 +2,7 @@
import algoliasearch from 'algoliasearch/lite'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import {
QuerySortOptions,
Sort,
@ -14,7 +15,6 @@ import {
import { Row } from './layout/row'
import { useEffect, useMemo, useState } from 'react'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
@ -49,6 +49,7 @@ export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
export function ContractSearch(props: {
user: User | null | undefined
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
additionalFilter?: {
creatorId?: string
@ -68,6 +69,7 @@ export function ContractSearch(props: {
headerClassName?: string
}) {
const {
user,
querySortOptions,
additionalFilter,
onContractClick,
@ -79,7 +81,6 @@ export function ContractSearch(props: {
headerClassName,
} = props
const user = useUser()
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
@ -234,7 +235,7 @@ export function ContractSearch(props: {
if (newFilter === filter) return
setFilter(newFilter)
setPage(0)
trackCallback('select search filter', { filter: newFilter })
track('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
@ -242,7 +243,7 @@ export function ContractSearch(props: {
setPage(0)
setSort(newSort)
track('select sort', { sort: newSort })
track('select search sort', { sort: newSort })
}
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
@ -267,6 +268,7 @@ export function ContractSearch(props: {
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })}
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
className="input input-bordered w-full"
/>
@ -347,7 +349,6 @@ export function ContractSearch(props: {
<ContractsGrid
contracts={hitsByPage[0] === undefined ? undefined : contracts}
loadMore={loadMore}
hasMore={true}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}

View File

@ -5,10 +5,10 @@ import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
import { ShowTime } from './contract-details'
import { ContractSearch } from '../contract-search'
import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
import { useCallback } from 'react'
import clsx from 'clsx'
import { LoadingIndicator } from '../loading-indicator'
import { VisibilityObserver } from '../visibility-observer'
export type ContractHighlightOptions = {
contractIds?: string[]
@ -17,8 +17,7 @@ export type ContractHighlightOptions = {
export function ContractsGrid(props: {
contracts: Contract[] | undefined
loadMore: () => void
hasMore: boolean
loadMore?: () => void
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
@ -31,7 +30,6 @@ export function ContractsGrid(props: {
const {
contracts,
showTime,
hasMore,
loadMore,
onContractClick,
overrideGridClassName,
@ -39,16 +37,15 @@ export function ContractsGrid(props: {
highlightOptions,
} = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem)
useEffect(() => {
if (isBottomVisible && hasMore) {
loadMore()
}
}, [isBottomVisible, hasMore, loadMore])
const onVisibilityUpdated = useCallback(
(visible) => {
if (visible && loadMore) {
loadMore()
}
},
[loadMore]
)
if (contracts === undefined) {
return <LoadingIndicator />
@ -92,16 +89,23 @@ export function ContractsGrid(props: {
/>
))}
</ul>
<div ref={setElem} className="relative -top-96 h-1" />
<VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1"
/>
</Col>
)
}
export function CreatorContractsList(props: { creator: User }) {
const { creator } = props
export function CreatorContractsList(props: {
user: User | null | undefined
creator: User
}) {
const { user, creator } = props
return (
<ContractSearch
user={user}
querySortOptions={{
defaultSort: 'newest',
defaultFilter: 'all',

View File

@ -319,7 +319,7 @@ function getProb(contract: Contract) {
? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE'
: outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
? getNumericScale(contract)

View File

@ -14,7 +14,9 @@ import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
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: {
contract: Contract
@ -26,36 +28,50 @@ export function ShareModal(props: {
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
? '?referrer=' + user?.username
: ''
}`
return (
<Modal open={isOpen} setOpen={setOpen}>
<Modal open={isOpen} setOpen={setOpen} size="md">
<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
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
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')
toast.success('Link copied!', {
icon: linkIcon,
})
}}
>
{linkIcon} Copy link
</Button>
<Row className="justify-start gap-4 self-center">
<Row className="z-0 justify-start gap-4 self-center">
<TweetButton
className="self-start"
tweetText={getTweetText(contract)}
tweetText={getTweetText(contract, shareUrl)}
/>
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
<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 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}`
}

View File

@ -3,7 +3,6 @@ import Placeholder from '@tiptap/extension-placeholder'
import {
useEditor,
EditorContent,
FloatingMenu,
JSONContent,
Content,
Editor,
@ -11,13 +10,11 @@ import {
import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query'
import { exhibitExts } from 'common/util/parse'
import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
@ -37,8 +34,20 @@ import { Spacer } from './layout/spacer'
import { MarketModal } from './editor/market-modal'
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(
'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'
)
@ -70,15 +79,11 @@ export function useTextEditor(props: {
Placeholder.configure({
placeholder,
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 }),
Image,
Link.configure({
HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass),
},
}),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({
suggestion: mentionSuggestion(users),
}),
@ -126,6 +131,13 @@ function isValidIframe(text: string) {
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: {
editor: Editor | null
upload: ReturnType<typeof useUploadMutation>
@ -139,15 +151,7 @@ export function TextEditor(props: {
<>
{/* hide placeholder when focused */}
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
{editor && (
<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">
<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">
<EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
@ -190,7 +194,14 @@ export function TextEditor(props: {
/>
</button>
</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}
</div>
</div>
@ -209,8 +220,9 @@ function IframeModal(props: {
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [embedCode, setEmbedCode] = useState('')
const valid = isValidIframe(embedCode)
const [input, setInput] = useState('')
const valid = isValidIframe(input) || isValidUrl(input)
const embedCode = isValidIframe(input) ? input : `<iframe src="${input}" />`
return (
<Modal open={open} setOpen={setOpen}>
@ -227,8 +239,8 @@ function IframeModal(props: {
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder='e.g. <iframe src="..."></iframe>'
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
{/* Preview the embed if it's valid */}
@ -240,7 +252,7 @@ function IframeModal(props: {
onClick={() => {
if (editor && valid) {
insertContent(editor, embedCode)
setEmbedCode('')
setInput('')
setOpen(false)
}
}}
@ -250,7 +262,7 @@ function IframeModal(props: {
<Button
color="gray"
onClick={() => {
setEmbedCode('')
setInput('')
setOpen(false)
}}
>
@ -280,14 +292,19 @@ const useUploadMutation = (editor: Editor | null) =>
}
)
function RichContent(props: { content: JSONContent | string }) {
const { content } = props
function RichContent(props: {
content: JSONContent | string
smallImage?: boolean
}) {
const { content, smallImage } = props
const editor = useEditor({
editorProps: { attributes: { class: proseClass } },
extensions: [
// replace tiptap's Mention with ours, to add style and link
...exhibitExts.filter((ex) => ex.name !== Mention.name),
StarterKit,
smallImage ? DisplayImage : Image,
DisplayLink,
DisplayMention,
Iframe,
],
content,
editable: false,
@ -298,13 +315,16 @@ function RichContent(props: { content: JSONContent | string }) {
}
// 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
return typeof content === 'string' ? (
<div className="whitespace-pre-line font-light leading-relaxed">
<Linkify text={content} />
</div>
) : (
<RichContent content={content} />
<RichContent {...props} />
)
}

View File

@ -36,38 +36,33 @@ export function FeedBet(props: {
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<EmptyAvatar />
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
/>
</div>
</Row>
</>
<Row className={'flex w-full items-center gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<EmptyAvatar className="mx-1" />
)}
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
className="flex-1"
/>
</Row>
)
}
@ -77,8 +72,9 @@ export function BetStatusText(props: {
isSelf: boolean
bettor?: User
hideOutcome?: boolean
className?: string
}) {
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
@ -123,7 +119,7 @@ export function BetStatusText(props: {
: formatPercent(bet.limitProb ?? bet.probAfter)
return (
<div className="text-sm text-gray-500">
<div className={clsx('text-sm text-gray-500', className)}>
{bettor ? (
<UserLink name={bettor.name} username={bettor.username} />
) : (

View File

@ -254,7 +254,7 @@ export function FeedComment(props: {
/>
</div>
<div className="mt-2 text-[15px] text-gray-700">
<Content content={content || text} />
<Content content={content || text} smallImage />
</div>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
@ -394,8 +394,8 @@ export function CommentInput(props: {
/>
</div>
<div className={'min-w-0 flex-1'}>
<div className="pl-0.5 text-sm text-gray-500">
<div className={'mb-1'}>
<div className="pl-0.5 text-sm">
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}

View File

@ -1,5 +1,5 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import React, { useState } from 'react'
import React from 'react'
import {
BanIcon,
CheckIcon,
@ -22,7 +22,6 @@ import { UserLink } from '../user-page'
import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time'
@ -50,11 +49,8 @@ export function FeedItems(props: {
const { contract, items, className, betRowClassName, user } = props
const { outcomeType } = contract
const [elem, setElem] = useState<HTMLElement | null>(null)
useSaveSeenContract(elem, contract)
return (
<div className={clsx('flow-root', className)} ref={setElem}>
<div className={clsx('flow-root', className)}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{items.map((item, activityItemIdx) => (
<div key={item.id} className={'relative pb-4'}>

View File

@ -338,7 +338,11 @@ const GroupMessage = memo(function GroupMessage_(props: {
</Row>
<div className="mt-2 text-black">
{comments.map((comment) => (
<Content content={comment.content || comment.text} />
<Content
key={comment.id}
content={comment.content || comment.text}
smallImage
/>
))}
</div>
<Row>

View File

@ -59,11 +59,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
Trending markets
</Row>
<ContractsGrid
contracts={hotContracts?.slice(0, 10) || []}
loadMore={() => {}}
hasMore={false}
/>
<ContractsGrid contracts={hotContracts?.slice(0, 10) || []} />
</>
)
}

View File

@ -30,6 +30,7 @@ import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
const logout = async () => {
// 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 (CHALLENGES_ENABLED)
return [
{ 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 [
return buildArray(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Charity', href: '/charity' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
)
}
if (CHALLENGES_ENABLED)
return [
{ 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 [
return buildArray(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
@ -105,6 +95,7 @@ function getMoreNavigation(user?: User | null) {
onClick: logout,
},
]
)
}
const signedOutNavigation = [
@ -141,29 +132,27 @@ const signedInMobileNavigation = [
]
function getMoreMobileNav() {
return [
...(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',
href: '#',
onClick: logout,
},
]
const signOut = {
name: 'Sign out',
href: '#',
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 = {
@ -328,8 +317,7 @@ function GroupsList(props: {
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight =
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo(
() => (itemHref: string) =>

View File

@ -61,7 +61,8 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
min: Math.min(...points.map((p) => p.y)),
}}
gridYValues={numYTickValues}
curve="monotoneX"
curve="stepAfter"
enablePoints={false}
colors={{ datum: 'color' }}
axisBottom={{
tickValues: numXTickValues,

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline'
import { LinkIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { copyToClipboard } from 'web/lib/util/copy'
@ -40,7 +40,7 @@ export function ShareIconButton(props: {
setTimeout(() => setShowToast(false), 2000)
}}
>
<ShareIcon
<LinkIcon
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
aria-hidden="true"
/>

View File

@ -37,7 +37,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
// declare debounced function only on first render
const [saveTip] = useState(() =>
debounce(async (user: User, change: number) => {
debounce(async (user: User, comment: Comment, change: number) => {
if (change === 0) {
return
}
@ -71,30 +71,24 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
// instant save on unrender
useEffect(() => () => void saveTip.flush(), [saveTip])
const changeTip = (tip: number) => {
setLocalTip(tip)
me && saveTip(me, tip - savedTip)
const addTip = (delta: number) => {
setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta)
}
const canDown = me && localTip > savedTip
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
return (
<Row className="items-center gap-0.5">
<DownTip
value={localTip}
onChange={changeTip}
disabled={!me || localTip <= savedTip}
/>
<DownTip onClick={canDown ? () => addTip(-5) : undefined} />
<span className="font-bold">{Math.floor(total)}</span>
<UpTip
value={localTip}
onChange={changeTip}
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
/>
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} />
{localTip === 0 ? (
''
) : (
<span
className={clsx(
'font-semibold',
'ml-1 font-semibold',
localTip > 0 ? 'text-primary' : 'text-red-400'
)}
>
@ -105,21 +99,17 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
)
}
function DownTip(prop: {
value: number
onChange: (tip: number) => void
disabled?: boolean
}) {
const { onChange, value, disabled } = prop
function DownTip(props: { onClick?: () => void }) {
const { onClick } = props
return (
<Tooltip
className="tooltip-bottom"
text={!disabled && `-${formatMoney(5)}`}
className="tooltip-bottom h-6 w-6"
text={onClick && `-${formatMoney(5)}`}
>
<button
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
disabled={disabled}
onClick={() => onChange(value - 5)}
className="hover:text-red-600 disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
@ -127,30 +117,20 @@ function DownTip(prop: {
)
}
function UpTip(prop: {
value: number
onChange: (tip: number) => void
disabled?: boolean
}) {
const { onChange, value, disabled } = prop
function UpTip(props: { onClick?: () => void; value: number }) {
const { onClick, value } = props
const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon
return (
<Tooltip
className="tooltip-bottom"
text={!disabled && `Tip ${formatMoney(5)}`}
className="tooltip-bottom h-6 w-6"
text={onClick && `Tip ${formatMoney(5)}`}
>
<button
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
disabled={disabled}
onClick={() => onChange(value + 5)}
className="hover:text-primary disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
{value >= 10 ? (
<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" />
)}
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
</button>
</Tooltip>
)

View File

@ -280,8 +280,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
}
>
<span>
Refer a friend and earn {formatMoney(500)} when they sign up! You
have <ReferralsButton user={user} currentUser={currentUser} />
<SiteLink href="/referrals">
Refer a friend and earn {formatMoney(500)} when they sign up!
</SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} />
</span>
<ShareIconButton
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
@ -300,7 +302,9 @@ export function UserPage(props: { user: User; currentUser?: User }) {
tabs={[
{
title: 'Markets',
content: <CreatorContractsList creator={user} />,
content: (
<CreatorContractsList user={currentUser} creator={user} />
),
tabIcon: (
<span className="px-0.5 font-bold">
{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 { storage } from './init'
const ONE_YEAR_SECS = 60 * 60 * 24 * 365
export const uploadImage = async (
username: string,
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 rejectPromise: (reason?: any) => void

View File

@ -54,7 +54,7 @@ export async function getUser(userId: string) {
export async function getPrivateUser(userId: string) {
/* 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) {

View File

@ -92,6 +92,7 @@ export default function ContractPage(props: {
slug: '',
}
const user = useUser()
const inIframe = useIsIframe()
if (inIframe) {
return <ContractEmbedPage {...props} />
@ -103,46 +104,15 @@ export default function ContractPage(props: {
return <Custom404 />
}
return <ContractPageContent {...{ ...props, contract }} />
return <ContractPageContent {...{ ...props, contract, user }} />
}
export function ContractPageContent(
props: Parameters<typeof ContractPage>[0] & { contract: Contract }
) {
const { backToHome, comments } = 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 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
export function ContractPageSidebar(props: {
user: User | null | undefined
contract: Contract
}) {
const { contract, user } = props
const { creatorId, isResolved, outcomeType } = contract
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
@ -153,14 +123,7 @@ export function ContractPageContent(
const hasSidePanel =
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
const ogCardProps = getOpenGraphProps(contract)
useSaveReferral(user, {
defaultReferrerUsername: contract.creatorUsername,
contractId: contract.id,
})
const rightSidebar = hasSidePanel ? (
return hasSidePanel ? (
<Col className="gap-4">
{allowTrade &&
(isNumeric ? (
@ -179,7 +142,57 @@ export function ContractPageContent(
))}
</Col>
) : 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 (
<Page rightSidebar={rightSidebar}>
{showConfetti && (
@ -216,7 +229,7 @@ export function ContractPageContent(
bets={bets.filter((b) => !b.challengeSlug)}
/>
{isNumeric && (
{outcomeType === 'NUMERIC' && (
<AlertBox
title="Warning"
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} />
)}

View File

@ -108,12 +108,7 @@ export default function ContractSearchFirestore(props: {
<option value="close-date">Closing soon</option>
</select>
</div>
<ContractsGrid
contracts={matches}
loadMore={() => {}}
hasMore={false}
showTime={showTime}
/>
<ContractsGrid contracts={matches} showTime={showTime} />
</div>
)
}

View File

@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
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 { useRouter } from 'next/router'
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 { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useState } from 'react'
import { GroupChatInBubble } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
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 { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { Comment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat'
export const getStaticProps = fromPropz(getStaticPropz)
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 user = useUser()
const privateUser = usePrivateUser(user?.id)
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
groupId: group?.id,
})
const chatDisabled = !group || group.chatDisabled
const showChatBubble = !chatDisabled
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
@ -201,6 +197,7 @@ export default function GroupPage(props: {
const questionsTab = (
<ContractSearch
user={user}
querySortOptions={{
shouldLoadFromStorage: true,
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 = [
{
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
},
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, 'chat'),
},
{
title: 'Leaderboards',
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 (
<Page>
@ -264,15 +272,6 @@ export default function GroupPage(props: {
defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs}
/>
{showChatBubble && (
<GroupChatInBubble
group={group}
user={user}
privateUser={privateUser}
tips={tips}
messages={messages}
/>
)}
</Page>
)
}
@ -614,6 +613,7 @@ function AddContractButton(props: { group: Group; user: User }) {
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch
user={user}
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
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 { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { getUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
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 router = useRouter()
@ -29,6 +35,7 @@ const Home = () => {
<Page suspend={!!contract}>
<Col className="mx-auto w-full p-2">
<ContractSearch
user={user}
querySortOptions={{
shouldLoadFromStorage: true,
defaultSort: getSavedSort() ?? DEFAULT_SORT,
@ -56,6 +63,7 @@ const Home = () => {
{contract && (
<ContractPageContent
contract={contract}
user={user}
username={contract.creatorUsername}
slug={contract.slug}
bets={[]}

View File

@ -26,6 +26,7 @@ import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink'
import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link'
const LINKS_PER_PAGE = 24
@ -69,9 +70,11 @@ export default function LinkPage(props: { user: User }) {
</Row>
<p>
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
for the referral bonus. Invite a new user to Manifold and get M$
{REFERRAL_AMOUNT} if they sign up!
don&apos;t yet have a Manifold account.{' '}
<SiteLink href="/referrals">
Eligible for {formatMoney(REFERRAL_AMOUNT)} referral bonus if a new
user signs up!
</SiteLink>
</p>
<Subtitle text="Your Manalinks" />
<ManalinksDisplay

View File

@ -1,9 +1,11 @@
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../components/contract-search'
import { Page } from '../components/page'
import { SEO } from '../components/SEO'
// TODO: Rename endpoint to "Explore"
export default function Markets() {
const user = useUser()
return (
<Page>
<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."
url="/markets"
/>
<ContractSearch />
<ContractSearch user={user} />
</Page>
)
}

View File

@ -9,6 +9,7 @@ import { REFERRAL_AMOUNT } from 'common/user'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code'
export const getServerSideProps = redirectIfLoggedOut('/')
@ -50,6 +51,8 @@ export default function ReferralsPage() {
toastClassName={'-left-28 mt-1'}
/>
<QRCode url={url} className="mt-4 self-center" />
<InfoBox
title="FYI"
className="mt-4 max-w-md"

View File

@ -1,10 +1,12 @@
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../../components/contract-search'
import { Page } from '../../components/page'
import { Title } from '../../components/title'
export default function TagPage() {
const router = useRouter()
const user = useUser()
const { tag } = router.query as { tag: string }
if (!router.isReady) return <div />
@ -12,6 +14,7 @@ export default function TagPage() {
<Page>
<Title text={`#${tag}`} />
<ContractSearch
user={user}
querySortOptions={{
defaultSort: 'newest',
defaultFilter: 'all',