From ebbb8905e2653d69120adb864ad1fd5a2e0c99ab Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Thu, 15 Sep 2022 16:05:56 -0700 Subject: [PATCH 01/31] Add clearer thinking Regrant to tournaments (#883) --- web/pages/tournaments/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index e81c239f..8ce11284 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -76,6 +76,13 @@ const Salem = { } const tourneys: Tourney[] = [ + { + title: 'Clearer Thinking Regrant Project', + blurb: 'Which projects will Clearer Thinking give a grant to?', + award: '$13,000', + endTime: toDate('Sep 22, 2022'), + groupId: 'fhksfIgqyWf7OxsV9nkM', + }, { title: 'Manifold F2P Tournament', blurb: @@ -99,13 +106,6 @@ const tourneys: Tourney[] = [ endTime: toDate('Jan 6, 2023'), groupId: 'SxGRqXRpV3RAQKudbcNb', }, - // { - // title: 'Clearer Thinking Regrant Project', - // blurb: 'Something amazing', - // award: '$10,000', - // endTime: toDate('Sep 22, 2022'), - // groupId: '2VsVVFGhKtIdJnQRAXVb', - // }, // Tournaments without awards get featured belows { From e0634cea6d7c0a169829c04ed061f602b4fcfb1e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 15 Sep 2022 18:19:22 -0500 Subject: [PATCH 02/31] Revert "Use %mention to embed a contract card in rich text editor (#869)" This reverts commit 140628692f9e09b6f2e582b31f88974dac581524. --- web/components/editor.tsx | 19 +---- .../editor/contract-mention-list.tsx | 68 ----------------- .../editor/contract-mention-suggestion.ts | 76 ------------------- web/components/editor/contract-mention.tsx | 41 ---------- web/hooks/use-contracts.ts | 9 +-- web/package.json | 1 - 6 files changed, 3 insertions(+), 211 deletions(-) delete mode 100644 web/components/editor/contract-mention-list.tsx delete mode 100644 web/components/editor/contract-mention-suggestion.ts delete mode 100644 web/components/editor/contract-mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 95f18b3f..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,8 +21,6 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' -import { contractMentionSuggestion } from './editor/contract-mention-suggestion' -import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -99,12 +97,7 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ - suggestion: mentionSuggestion, - }), - DisplayContractMention.configure({ - suggestion: contractMentionSuggestion, - }), + DisplayMention.configure({ suggestion: mentionSuggestion }), Iframe, TiptapTweet, ], @@ -323,21 +316,13 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, - DisplayContractMention.configure({ - // Needed to set a different PluginKey for Prosemirror - suggestion: contractMentionSuggestion, - }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect( - // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 - () => void !editor?.isDestroyed && editor?.commands?.setContent(content), - [editor, content] - ) + useEffect(() => void editor?.commands?.setContent(content), [editor, content]) return } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx deleted file mode 100644 index bda9d2fc..00000000 --- a/web/components/editor/contract-mention-list.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { SuggestionProps } from '@tiptap/suggestion' -import clsx from 'clsx' -import { Contract } from 'common/contract' -import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' -import { contractPath } from 'web/lib/firebase/contracts' -import { Avatar } from '../avatar' - -// copied from https://tiptap.dev/api/nodes/mention#usage -const M = forwardRef((props: SuggestionProps, ref) => { - const { items: contracts, command } = props - - const [selectedIndex, setSelectedIndex] = useState(0) - useEffect(() => setSelectedIndex(0), [contracts]) - - const submitUser = (index: number) => { - const contract = contracts[index] - if (contract) - command({ id: contract.id, label: contractPath(contract) } as any) - } - - const onUp = () => - setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) - const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) - const onEnter = () => submitUser(selectedIndex) - - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: any) => { - if (event.key === 'ArrowUp') { - onUp() - return true - } - if (event.key === 'ArrowDown') { - onDown() - return true - } - if (event.key === 'Enter') { - onEnter() - return true - } - return false - }, - })) - - return ( -
- {!contracts.length ? ( - No results... - ) : ( - contracts.map((contract, i) => ( - - )) - )} -
- ) -}) - -// Just to keep the formatting pretty -export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts deleted file mode 100644 index 79525cfc..00000000 --- a/web/components/editor/contract-mention-suggestion.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { MentionOptions } from '@tiptap/extension-mention' -import { ReactRenderer } from '@tiptap/react' -import { searchInAny } from 'common/util/parse' -import { orderBy } from 'lodash' -import tippy from 'tippy.js' -import { getCachedContracts } from 'web/hooks/use-contracts' -import { MentionList } from './contract-mention-list' -import { PluginKey } from 'prosemirror-state' - -type Suggestion = MentionOptions['suggestion'] - -const beginsWith = (text: string, query: string) => - text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) - -// copied from https://tiptap.dev/api/nodes/mention#usage -// TODO: merge with mention-suggestion.ts? -export const contractMentionSuggestion: Suggestion = { - char: '%', - allowSpaces: true, - pluginKey: new PluginKey('contract-mention'), - items: async ({ query }) => - orderBy( - (await getCachedContracts()).filter((c) => - searchInAny(query, c.question) - ), - [(c) => [c.question].some((s) => beginsWith(s, query))], - ['desc', 'desc'] - ).slice(0, 5), - render: () => { - let component: ReactRenderer - let popup: ReturnType - return { - onStart: (props) => { - component = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }) - if (!props.clientRect) { - return - } - - popup = tippy('body', { - getReferenceClientRect: props.clientRect as any, - appendTo: () => document.body, - content: component?.element, - showOnCreate: true, - interactive: true, - trigger: 'manual', - placement: 'bottom-start', - }) - }, - onUpdate(props) { - component?.updateProps(props) - - if (!props.clientRect) { - return - } - - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect as any, - }) - }, - onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } - return (component?.ref as any)?.onKeyDown(props) - }, - onExit() { - popup?.[0].destroy() - component?.destroy() - }, - } - }, -} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx deleted file mode 100644 index 9e967044..00000000 --- a/web/components/editor/contract-mention.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Mention from '@tiptap/extension-mention' -import { - mergeAttributes, - NodeViewWrapper, - ReactNodeViewRenderer, -} from '@tiptap/react' -import clsx from 'clsx' -import { useContract } from 'web/hooks/use-contract' -import { ContractCard } from '../contract/contract-card' - -const name = 'contract-mention-component' - -const ContractMentionComponent = (props: any) => { - const contract = useContract(props.node.attrs.id) - - return ( - - {contract && ( - - )} - - ) -} - -/** - * Mention extension that renders React. See: - * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions - * https://tiptap.dev/guide/node-views/react#render-a-react-component - */ -export const DisplayContractMention = Mention.extend({ - parseHTML: () => [{ tag: name }], - renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], - addNodeView: () => - ReactNodeViewRenderer(ContractMentionComponent, { - // On desktop, render cards below half-width so you can stack two - className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', - }), -}) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 87eefa38..1ea2f232 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,9 +9,8 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, - listAllContracts, } from 'web/lib/firebase/contracts' -import { QueryClient, useQueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -24,12 +23,6 @@ export const useContracts = () => { return contracts } -const q = new QueryClient() -export const getCachedContracts = async () => - q.fetchQuery(['contracts'], () => listAllContracts(1000), { - staleTime: Infinity, - }) - export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index ba25a6e1..114ded1e 100644 --- a/web/package.json +++ b/web/package.json @@ -48,7 +48,6 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", - "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1", From 5a1cc4c19d3444a32764f811131530a5dd8889e0 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Thu, 15 Sep 2022 18:32:30 -0500 Subject: [PATCH 03/31] getCpmmInvested: fix NaN issue --- common/calculate.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/common/calculate.ts b/common/calculate.ts index da4ce13a..e4c9ed07 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) { const { outcome, shares, amount } = bet if (floatingEqual(shares, 0)) continue + const spent = totalSpent[outcome] ?? 0 + const position = totalShares[outcome] ?? 0 + if (amount > 0) { - totalShares[outcome] = (totalShares[outcome] ?? 0) + shares - totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + amount } else if (amount < 0) { - const averagePrice = totalSpent[outcome] / totalShares[outcome] - totalShares[outcome] = totalShares[outcome] + shares - totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares + const averagePrice = position === 0 ? 0 : spent / position + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + averagePrice * shares } } - return sum(Object.values(totalSpent)) + return sum([0, ...Object.values(totalSpent)]) } function getDpmInvested(yourBets: Bet[]) { From 1ce989f3d64ed6ffc5b68a6be49eba4f519cae6f Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 15 Sep 2022 19:41:25 -0500 Subject: [PATCH 04/31] Inga/bettingfix embedfix (#885) * Revert "Revert "Inga/bettingfix (#879)"" This reverts commit 176acf959fe9fe092dfd84fb446062bf916e5734. * added embed fix --- web/components/button.tsx | 5 +- web/components/contract/contract-details.tsx | 307 +++++++++++------- .../contract/contract-info-dialog.tsx | 11 +- web/components/contract/contract-overview.tsx | 12 +- .../contract/extra-contract-actions-row.tsx | 57 +--- .../contract/like-market-button.tsx | 20 +- web/components/follow-button.tsx | 67 +++- web/components/follow-market-button.tsx | 16 +- web/pages/[username]/[contractSlug].tsx | 2 - web/pages/embed/[username]/[contractSlug].tsx | 64 ++-- web/tailwind.config.js | 2 + 11 files changed, 328 insertions(+), 235 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index cb39cba8..ea9a3e88 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,6 +11,7 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' + | 'highlight-blue' export function Button(props: { className?: string @@ -56,7 +57,9 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', + 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', + color === 'highlight-blue' && + 'text-highlight-blue border-none shadow-none', className )} disabled={disabled} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 0a65d4d9..d5b7b796 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,9 +1,4 @@ -import { - ClockIcon, - DatabaseIcon, - PencilIcon, - UserGroupIcon, -} from '@heroicons/react/outline' +import { ClockIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' -import { ContractInfoDialog } from './contract-info-dialog' import NewContractBadge from '../new-contract-badge' -import { UserFollowButton } from '../follow-button' +import { MiniUserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' @@ -34,6 +28,9 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { useWindowSize } from 'web/hooks/use-window-size' +import { ExtraContractActionsRow } from './extra-contract-actions-row' +import { PlusCircleIcon } from '@heroicons/react/solid' +import { GroupLink } from 'common/group' export type ShowTime = 'resolve-date' | 'close-date' @@ -111,90 +108,157 @@ export function AvatarDetails(props: { ) } +export function useIsMobile() { + const { width } = useWindowSize() + return (width ?? 0) < 600 +} + export function ContractDetails(props: { contract: Contract disabled?: boolean }) { const { contract, disabled } = props - const { - closeTime, - creatorName, - creatorUsername, - creatorId, - creatorAvatarUrl, - resolutionTime, - } = contract - const { volumeLabel, resolvedDate } = contractMetrics(contract) - const user = useUser() - const isCreator = user?.id === creatorId - const [open, setOpen] = useState(false) - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 600 - const groupToDisplay = getGroupLinkToDisplay(contract) - const groupInfo = groupToDisplay ? ( - - - - {groupToDisplay.name} - - - ) : ( - - ) + const isMobile = useIsMobile() return ( - - - - {disabled ? ( - creatorName - ) : ( - - )} - {!disabled && } + + + +
+ +
- - {disabled ? ( - groupInfo - ) : !groupToDisplay && !user ? ( -
- ) : ( + {/* GROUPS */} + {isMobile && ( +
+ +
+ )} + + ) +} + +export function MarketSubheader(props: { + contract: Contract + disabled?: boolean +}) { + const { contract, disabled } = props + const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract + const { resolvedDate } = contractMetrics(contract) + const user = useUser() + const isCreator = user?.id === creatorId + const isMobile = useIsMobile() + return ( + + + {!disabled && ( +
+ +
+ )} + + + {disabled ? ( + creatorName + ) : ( + + )} + + + + {!isMobile && ( + + )} + + +
+ ) +} + +export function CloseOrResolveTime(props: { + contract: Contract + resolvedDate: any + isCreator: boolean +}) { + const { contract, resolvedDate, isCreator } = props + const { resolutionTime, closeTime } = contract + console.log(closeTime, resolvedDate) + if (!!closeTime || !!resolvedDate) { + return ( + + {resolvedDate && resolutionTime ? ( + <> + + +
resolved 
+ {resolvedDate} +
+
+ + ) : null} + + {!resolvedDate && closeTime && ( - {groupInfo} - {user && groupToDisplay && ( -
} + {!dayjs().isBefore(closeTime) &&
closed 
} + +
+ )} +
+ ) + } else return <> +} + +export function MarketGroups(props: { + contract: Contract + isMobile: boolean | undefined + disabled: boolean | undefined +}) { + const [open, setOpen] = useState(false) + const user = useUser() + const { contract, isMobile, disabled } = props + const groupToDisplay = getGroupLinkToDisplay(contract) + + return ( + <> + + + {!disabled && ( + + {user && ( + + + )} )} @@ -208,45 +272,7 @@ export function ContractDetails(props: { - - {(!!closeTime || !!resolvedDate) && ( - - {resolvedDate && resolutionTime ? ( - <> - - - {resolvedDate} - - - ) : null} - - {!resolvedDate && closeTime && user && ( - <> - - - - )} - - )} - {user && ( - <> - - -
{volumeLabel}
-
- {!disabled && ( - - )} - - )} -
+ ) } @@ -287,12 +313,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( + Closes  - Ends ) )} @@ -312,6 +338,45 @@ export function ExtraMobileContractDetails(props: { ) } +export function GroupDisplay(props: { + groupToDisplay?: GroupLink | null + isMobile?: boolean +}) { + const { groupToDisplay, isMobile } = props + if (groupToDisplay) { + return ( + + +
+ {groupToDisplay.name} +
+
+ + ) + } else + return ( + +
+ No Group +
+
+ ) +} + function EditableCloseDate(props: { closeTime: number contract: Contract diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 76a48277..26c18484 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,6 +19,7 @@ import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' import { BETTORS } from 'common/user' +import { Button } from '../button' import { capitalize } from 'lodash' export const contractDetailsButtonClassName = @@ -69,19 +70,21 @@ export function ContractInfoDialog(props: { return ( <> - + - + <Title className="!mt-0 !mb-0" text="This Market" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 1bfe84de..bfb4829f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails, ExtraMobileContractDetails } from './contract-details' +import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> + <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-3 px-2 sm:gap-4"> + <Col className="gap-1 px-2"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -113,10 +112,6 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - forceShowVolume={true} - /> </Col> </Col> ) @@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 5d5ee4d8..af5db9c3 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' -import { withTracking } from 'web/lib/service/analytics' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/challenge' -import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props - const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> + <Row> + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} <Button - size="lg" + size="sm" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Col className={'items-center sm:flex-row'}> - <ShareIcon - className={clsx('h-[24px] w-5 sm:mr-2')} - aria-hidden="true" - /> - <span>Share</span> - </Col> + <Row> + <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> + </Row> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - - {showChallenge && ( - <Button - size="lg" - color="gray-white" - className="max-w-xs self-center" - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <Col className="items-center sm:flex-row"> - <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> - <span>Challenge</span> - </Col> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={setOpenCreateChallengeModal} - user={user} - contract={contract} - /> - </Button> - )} - - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} - <Col className={'justify-center md:hidden'}> + <Col className={'justify-center'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index e35e3e7e..01dce32f 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,15 +38,16 @@ export function LikeMarketButton(props: { return ( <Button - size={'lg'} + size={'sm'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row'}> + <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-[24px] w-5 sm:mr-2', + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -54,7 +55,18 @@ export function LikeMarketButton(props: { : '' )} /> - Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} </Col> </Button> ) diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 09495169..6344757d 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,4 +1,6 @@ +import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -54,18 +56,73 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const currentUser = useUser() - const following = useFollows(currentUser?.id) + const user = useUser() + const following = useFollows(user?.id) const isFollowing = following?.includes(userId) - if (!currentUser || currentUser.id === userId) return null + if (!user || user.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(currentUser.id, userId)} - onUnfollow={() => unfollow(currentUser.id, userId)} + onFollow={() => follow(user.id, userId)} + onUnfollow={() => unfollow(user.id, userId)} small={small} /> ) } + +export function MiniUserFollowButton(props: { userId: string }) { + const { userId } = props + const user = useUser() + const following = useFollows(user?.id) + const isFollowing = following?.includes(userId) + const isFirstRender = useRef(true) + const [justFollowed, setJustFollowed] = useState(false) + + useEffect(() => { + if (isFirstRender.current) { + if (isFollowing != undefined) { + isFirstRender.current = false + } + return + } + if (isFollowing) { + setJustFollowed(true) + setTimeout(() => { + setJustFollowed(false) + }, 1000) + } + }, [isFollowing]) + + if (justFollowed) { + return ( + <CheckCircleIcon + className={clsx( + 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + ) + } + if ( + !user || + user.id === userId || + isFollowing || + !user || + isFollowing === undefined + ) + return null + return ( + <> + <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> + <PlusCircleIcon + className={clsx( + 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + </button> + </> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 1dd261cb..0e65165b 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'lg'} + size={'sm'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,13 +56,19 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Unwatch + <EyeOffIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Watch + <EyeIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Watch */} </Col> )} <WatchMarketModal diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2c011c90..a0b2ed50 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,7 +37,6 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -257,7 +256,6 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> - <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index fbeef88f..62dd1ae1 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -11,7 +11,7 @@ import { NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' -import { ContractDetails } from 'web/components/contract/contract-details' +import { MarketSubheader } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' import { NumericGraph } from 'web/components/contract/numeric-graph' import { Col } from 'web/components/layout/col' @@ -102,50 +102,40 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { return ( <Col className="h-[100vh] w-full bg-white"> - <div className="relative flex flex-col pt-2"> - <div className="px-3 text-xl text-indigo-700 md:text-2xl"> + <Row className="justify-between gap-4 px-2"> + <div className="text-xl text-indigo-700 md:text-2xl"> <SiteLink href={href}>{question}</SiteLink> </div> + {isBinary && ( + <BinaryResolutionOrChance contract={contract} probAfter={probAfter} /> + )} - <Spacer h={3} /> + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation contract={contract} /> + )} - <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails contract={contract} disabled /> + {outcomeType === 'FREE_RESPONSE' && ( + <FreeResponseResolutionOrChance contract={contract} truncate="long" /> + )} - {(isBinary || isPseudoNumeric) && - tradingAllowed(contract) && - !betPanelOpen && ( - <Button color="gradient" onClick={() => setBetPanelOpen(true)}> - Predict - </Button> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation contract={contract} /> + )} + </Row> + <Spacer h={3} /> + <Row className="items-center justify-between gap-4 px-2"> + <MarketSubheader contract={contract} disabled /> - {isBinary && ( - <BinaryResolutionOrChance - contract={contract} - probAfter={probAfter} - className="items-center" - /> + {(isBinary || isPseudoNumeric) && + tradingAllowed(contract) && + !betPanelOpen && ( + <Button color="gradient" onClick={() => setBetPanelOpen(true)}> + Predict + </Button> )} + </Row> - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation contract={contract} /> - )} - - {outcomeType === 'FREE_RESPONSE' && ( - <FreeResponseResolutionOrChance - contract={contract} - truncate="long" - /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation contract={contract} /> - )} - </Row> - - <Spacer h={2} /> - </div> + <Spacer h={2} /> {(isBinary || isPseudoNumeric) && betPanelOpen && ( <BetInline diff --git a/web/tailwind.config.js b/web/tailwind.config.js index eb411216..7bea3ec2 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,6 +26,8 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', + 'highlight-blue': '#5BCEFF', + 'hover-blue': '#90DEFF', }, typography: { quoteless: { From 430ad1acb035f25db7c88ac6e7fe0be604942b1c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 15 Sep 2022 23:08:31 -0500 Subject: [PATCH 05/31] "unique bettors"; "Unknown" => "0" --- .../contract/contract-info-dialog.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 26c18484..5187030d 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline' import clsx from 'clsx' import dayjs from 'dayjs' import { useState } from 'react' +import { capitalize } from 'lodash' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' @@ -20,7 +21,6 @@ import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' import { BETTORS } from 'common/user' import { Button } from '../button' -import { capitalize } from 'lodash' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -40,10 +40,16 @@ export function ContractInfoDialog(props: { const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') - const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = - contract + const { + createdTime, + closeTime, + resolutionTime, + uniqueBettorCount, + mechanism, + outcomeType, + id, + } = contract - const bettorsCount = contract.uniqueBettorCount ?? 'Unknown' const typeDisplay = outcomeType === 'BINARY' ? 'YES / NO' @@ -134,14 +140,9 @@ export function ContractInfoDialog(props: { <td>{formatMoney(contract.volume)}</td> </tr> - {/* <tr> - <td>Creator earnings</td> - <td>{formatMoney(contract.collectedFees.creatorFee)}</td> - </tr> */} - <tr> <td>{capitalize(BETTORS)}</td> - <td>{bettorsCount}</td> + <td>{uniqueBettorCount ?? '0'}</td> </tr> <tr> From ca4a2bc7db44db7ebf7f739092a006680d733785 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 15 Sep 2022 22:01:51 -0700 Subject: [PATCH 06/31] Remove console log --- web/components/contract/contract-details.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index d5b7b796..b6623625 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -205,7 +205,6 @@ export function CloseOrResolveTime(props: { }) { const { contract, resolvedDate, isCreator } = props const { resolutionTime, closeTime } = contract - console.log(closeTime, resolvedDate) if (!!closeTime || !!resolvedDate) { return ( <Row className="select-none items-center gap-1"> From 1321b95eb16f0fd5154d50c0a7614a3a80bea5c2 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 15 Sep 2022 23:37:17 -0700 Subject: [PATCH 07/31] %mentions for embedding contract card, take 2 (#884) * Revert "Revert "Use %mention to embed a contract card in rich text editor (#869)"" This reverts commit e0634cea6d7c0a169829c04ed061f602b4fcfb1e. * Overwrite name to prevent breakages * Fix '%' mentioning if you escape out * Cleanup: merge render functions --- web/components/editor.tsx | 19 +++++- .../editor/contract-mention-list.tsx | 68 +++++++++++++++++++ .../editor/contract-mention-suggestion.ts | 27 ++++++++ web/components/editor/contract-mention.tsx | 42 ++++++++++++ web/components/editor/mention-suggestion.ts | 25 +++++-- web/hooks/use-contracts.ts | 9 ++- web/package.json | 1 + 7 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 web/components/editor/contract-mention-list.tsx create mode 100644 web/components/editor/contract-mention-suggestion.ts create mode 100644 web/components/editor/contract-mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 745fc3c5..95f18b3f 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' +import { contractMentionSuggestion } from './editor/contract-mention-suggestion' +import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -97,7 +99,12 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ suggestion: mentionSuggestion }), + DisplayMention.configure({ + suggestion: mentionSuggestion, + }), + DisplayContractMention.configure({ + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], @@ -316,13 +323,21 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, + DisplayContractMention.configure({ + // Needed to set a different PluginKey for Prosemirror + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect(() => void editor?.commands?.setContent(content), [editor, content]) + useEffect( + // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 + () => void !editor?.isDestroyed && editor?.commands?.setContent(content), + [editor, content] + ) return <EditorContent className={className} editor={editor} /> } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx new file mode 100644 index 00000000..bda9d2fc --- /dev/null +++ b/web/components/editor/contract-mention-list.tsx @@ -0,0 +1,68 @@ +import { SuggestionProps } from '@tiptap/suggestion' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { contractPath } from 'web/lib/firebase/contracts' +import { Avatar } from '../avatar' + +// copied from https://tiptap.dev/api/nodes/mention#usage +const M = forwardRef((props: SuggestionProps<Contract>, ref) => { + const { items: contracts, command } = props + + const [selectedIndex, setSelectedIndex] = useState(0) + useEffect(() => setSelectedIndex(0), [contracts]) + + const submitUser = (index: number) => { + const contract = contracts[index] + if (contract) + command({ id: contract.id, label: contractPath(contract) } as any) + } + + const onUp = () => + setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) + const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) + const onEnter = () => submitUser(selectedIndex) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: any) => { + if (event.key === 'ArrowUp') { + onUp() + return true + } + if (event.key === 'ArrowDown') { + onDown() + return true + } + if (event.key === 'Enter') { + onEnter() + return true + } + return false + }, + })) + + return ( + <div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {!contracts.length ? ( + <span className="m-1 whitespace-nowrap">No results...</span> + ) : ( + contracts.map((contract, i) => ( + <button + className={clsx( + 'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200', + selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900' + )} + onClick={() => submitUser(i)} + key={contract.id} + > + <Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" /> + {contract.question} + </button> + )) + )} + </div> + ) +}) + +// Just to keep the formatting pretty +export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts new file mode 100644 index 00000000..39d024e7 --- /dev/null +++ b/web/components/editor/contract-mention-suggestion.ts @@ -0,0 +1,27 @@ +import type { MentionOptions } from '@tiptap/extension-mention' +import { searchInAny } from 'common/util/parse' +import { orderBy } from 'lodash' +import { getCachedContracts } from 'web/hooks/use-contracts' +import { MentionList } from './contract-mention-list' +import { PluginKey } from 'prosemirror-state' +import { makeMentionRender } from './mention-suggestion' + +type Suggestion = MentionOptions['suggestion'] + +const beginsWith = (text: string, query: string) => + text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) + +export const contractMentionSuggestion: Suggestion = { + char: '%', + allowSpaces: true, + pluginKey: new PluginKey('contract-mention'), + items: async ({ query }) => + orderBy( + (await getCachedContracts()).filter((c) => + searchInAny(query, c.question) + ), + [(c) => [c.question].some((s) => beginsWith(s, query))], + ['desc', 'desc'] + ).slice(0, 5), + render: makeMentionRender(MentionList), +} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx new file mode 100644 index 00000000..ddc81bc0 --- /dev/null +++ b/web/components/editor/contract-mention.tsx @@ -0,0 +1,42 @@ +import Mention from '@tiptap/extension-mention' +import { + mergeAttributes, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react' +import clsx from 'clsx' +import { useContract } from 'web/hooks/use-contract' +import { ContractCard } from '../contract/contract-card' + +const name = 'contract-mention-component' + +const ContractMentionComponent = (props: any) => { + const contract = useContract(props.node.attrs.id) + + return ( + <NodeViewWrapper className={clsx(name, 'not-prose')}> + {contract && ( + <ContractCard + contract={contract} + className="my-2 w-full border border-gray-100" + /> + )} + </NodeViewWrapper> + ) +} + +/** + * Mention extension that renders React. See: + * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions + * https://tiptap.dev/guide/node-views/react#render-a-react-component + */ +export const DisplayContractMention = Mention.extend({ + name: 'contract-mention', + parseHTML: () => [{ tag: name }], + renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], + addNodeView: () => + ReactNodeViewRenderer(ContractMentionComponent, { + // On desktop, render cards below half-width so you can stack two + className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', + }), +}) diff --git a/web/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts index 9f016d47..b4eeeebe 100644 --- a/web/components/editor/mention-suggestion.ts +++ b/web/components/editor/mention-suggestion.ts @@ -5,6 +5,7 @@ import { orderBy } from 'lodash' import tippy from 'tippy.js' import { getCachedUsers } from 'web/hooks/use-users' import { MentionList } from './mention-list' +type Render = Suggestion['render'] type Suggestion = MentionOptions['suggestion'] @@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = { ], ['desc', 'desc'] ).slice(0, 5), - render: () => { + render: makeMentionRender(MentionList), +} + +export function makeMentionRender(mentionList: any): Render { + return () => { let component: ReactRenderer let popup: ReturnType<typeof tippy> return { onStart: (props) => { - component = new ReactRenderer(MentionList, { + component = new ReactRenderer(mentionList, { props, editor: props.editor, }) @@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = { }) }, onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } + if (props.event.key) + if ( + props.event.key === 'Escape' || + // Also break out of the mention if the tooltip isn't visible + (props.event.key === 'Enter' && !popup?.[0].state.isShown) + ) { + popup?.[0].destroy() + component?.destroy() + return false + } return (component?.ref as any)?.onKeyDown(props) }, onExit() { @@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = { component?.destroy() }, } - }, + } } diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 1ea2f232..87eefa38 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,8 +9,9 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, + listAllContracts, } from 'web/lib/firebase/contracts' -import { useQueryClient } from 'react-query' +import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -23,6 +24,12 @@ export const useContracts = () => { return contracts } +const q = new QueryClient() +export const getCachedContracts = async () => + q.fetchQuery(['contracts'], () => listAllContracts(1000), { + staleTime: Infinity, + }) + export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index 114ded1e..ba25a6e1 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", + "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1", From 833ec518b42b82e53a5a1ae3010ecfba75171adb Mon Sep 17 00:00:00 2001 From: Phil <phil.bladen@gmail.com> Date: Fri, 16 Sep 2022 08:22:13 +0100 Subject: [PATCH 08/31] Twitch prerelease (#882) * Bot linking button functional. * Implemented initial prototype of new Twitch signup page. * Removed old Twitch signup page. * Moved new Twitch page to correct URL. * Twitch account linking functional. * Fixed charity link. * Changed to point to live bot server. * Slightly improve spacing and alignment on Twitch page * Tidy up, handle some errors when talking to bot * Seriously do the thing where Twitch link is hidden by default Co-authored-by: Marshall Polaris <marshall@pol.rs> --- web/components/profile/twitch-panel.tsx | 95 +++++++--- web/lib/twitch/link-twitch-account.ts | 53 ++++-- web/pages/profile.tsx | 6 +- web/pages/twitch.tsx | 230 ++++++++++++++++++++++-- web/public/twitch-glitch.svg | 21 +++ 5 files changed, 347 insertions(+), 58 deletions(-) create mode 100644 web/public/twitch-glitch.svg diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx index b284b242..a37b21dc 100644 --- a/web/components/profile/twitch-panel.tsx +++ b/web/components/profile/twitch-panel.tsx @@ -6,38 +6,101 @@ import { LinkIcon } from '@heroicons/react/solid' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { updatePrivateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { + linkTwitchAccountRedirect, + updateBotEnabledForUser, +} from 'web/lib/twitch/link-twitch-account' import { copyToClipboard } from 'web/lib/util/copy' import { Button, ColorType } from './../button' import { Row } from './../layout/row' import { LoadingIndicator } from './../loading-indicator' +import { PrivateUser } from 'common/user' function BouncyButton(props: { children: ReactNode onClick?: MouseEventHandler<any> color?: ColorType + className?: string }) { - const { children, onClick, color } = props + const { children, onClick, color, className } = props return ( <Button color={color} size="lg" onClick={onClick} - className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" + className={clsx( + 'btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case', + className + )} > {children} </Button> ) } +function BotConnectButton(props: { + privateUser: PrivateUser | null | undefined +}) { + const { privateUser } = props + const [loading, setLoading] = useState(false) + + const updateBotConnected = (connected: boolean) => async () => { + if (!privateUser) return + const twitchInfo = privateUser.twitchInfo + if (!twitchInfo) return + + const error = connected + ? 'Failed to add bot to your channel' + : 'Failed to remove bot from your channel' + const success = connected + ? 'Added bot to your channel' + : 'Removed bot from your channel' + + setLoading(true) + toast.promise( + updateBotEnabledForUser(privateUser, connected).then(() => + updatePrivateUser(privateUser.id, { + twitchInfo: { ...twitchInfo, botEnabled: connected }, + }) + ), + { loading: 'Updating bot settings...', error, success } + ) + try { + } finally { + setLoading(false) + } + } + + return ( + <> + {privateUser?.twitchInfo?.botEnabled ? ( + <BouncyButton + color="red" + onClick={updateBotConnected(false)} + className={clsx(loading && 'btn-disabled')} + > + Remove bot from your channel + </BouncyButton> + ) : ( + <BouncyButton + color="green" + onClick={updateBotConnected(true)} + className={clsx(loading && 'btn-disabled')} + > + Add bot to your channel + </BouncyButton> + )} + </> + ) +} + export function TwitchPanel() { const user = useUser() const privateUser = usePrivateUser() const twitchInfo = privateUser?.twitchInfo - const twitchName = privateUser?.twitchInfo?.twitchName - const twitchToken = privateUser?.twitchInfo?.controlToken - const twitchBotConnected = privateUser?.twitchInfo?.botEnabled + const twitchName = twitchInfo?.twitchName + const twitchToken = twitchInfo?.controlToken const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> @@ -55,13 +118,6 @@ export function TwitchPanel() { }) } - const updateBotConnected = (connected: boolean) => async () => { - if (user && twitchInfo) { - twitchInfo.botEnabled = connected - await updatePrivateUser(user.id, { twitchInfo }) - } - } - const [twitchLoading, setTwitchLoading] = useState(false) const createLink = async () => { @@ -115,17 +171,12 @@ export function TwitchPanel() { <BouncyButton color="indigo" onClick={copyDockLink}> Copy dock link </BouncyButton> - {twitchBotConnected ? ( - <BouncyButton color="red" onClick={updateBotConnected(false)}> - Remove bot from your channel - </BouncyButton> - ) : ( - <BouncyButton color="green" onClick={updateBotConnected(true)}> - Add bot to your channel - </BouncyButton> - )} </div> </div> + <div className="mt-4" /> + <div className="flex w-full"> + <BotConnectButton privateUser={privateUser} /> + </div> </div> )} </> diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts index 36fb12b5..71bc847d 100644 --- a/web/lib/twitch/link-twitch-account.ts +++ b/web/lib/twitch/link-twitch-account.ts @@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key' const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately +async function postToBot(url: string, body: unknown) { + const result = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await result.json() + if (!result.ok) { + throw new Error(json.message) + } else { + return json + } +} + export async function initLinkTwitchAccount( manifoldUserID: string, manifoldUserAPIKey: string ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { - const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - manifoldID: manifoldUserID, - apiKey: manifoldUserAPIKey, - redirectURL: window.location.href, - }), + const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, }) - const responseData = await response.json() - if (!response.ok) { - throw new Error(responseData.message) - } const responseFetch = fetch( `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` ) - return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] + return [response.twitchAuthURL, responseFetch.then((r) => r.json())] } export async function linkTwitchAccountRedirect( @@ -39,3 +43,22 @@ export async function linkTwitchAccountRedirect( window.location.href = twitchAuthURL } + +export async function updateBotEnabledForUser( + privateUser: PrivateUser, + botEnabled: boolean +) { + if (botEnabled) { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } else { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } +} diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 6b70b5d2..44a63b2d 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' - +import { useRouter } from 'next/router' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' @@ -64,6 +64,7 @@ function EditUserField(props: { export default function ProfilePage(props: { auth: { user: User; privateUser: PrivateUser } }) { + const router = useRouter() const { user, privateUser } = props.auth const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarLoading, setAvatarLoading] = useState(false) @@ -237,8 +238,7 @@ export default function ProfilePage(props: { </button> </div> </div> - - <TwitchPanel /> + {router.query.twitch && <TwitchPanel />} </Col> </Col> </Page> diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 7ca892e8..a21c1105 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -1,29 +1,34 @@ +import { PrivateUser, User } from 'common/user' +import Link from 'next/link' import { useState } from 'react' -import { Page } from 'web/components/page' +import toast from 'react-hot-toast' +import { Button } from 'web/components/button' import { Col } from 'web/components/layout/col' -import { ManifoldLogo } from 'web/components/nav/manifold-logo' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { SEO } from 'web/components/SEO' +import { Row } from 'web/components/layout/row' import { Spacer } from 'web/components/layout/spacer' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { useTracking } from 'web/hooks/use-tracking' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' -import { Button } from 'web/components/button' -import { useTracking } from 'web/hooks/use-tracking' import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { LoadingIndicator } from 'web/components/loading-indicator' -import toast from 'react-hot-toast' -export default function TwitchLandingPage() { - useSaveReferral() - useTracking('view twitch landing page') +function TwitchPlaysManifoldMarkets(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props - const user = useUser() - const privateUser = usePrivateUser() const twitchUser = privateUser?.twitchInfo?.twitchName + const [isLoading, setLoading] = useState(false) + const callback = user && privateUser ? () => linkTwitchAccountRedirect(user, privateUser) @@ -37,8 +42,6 @@ export default function TwitchLandingPage() { await linkTwitchAccountRedirect(user, privateUser) } - const [isLoading, setLoading] = useState(false) - const getStarted = async () => { try { setLoading(true) @@ -53,6 +56,191 @@ export default function TwitchLandingPage() { } } + return ( + <div> + <Row className="mb-4"> + <img + src="/twitch-glitch.svg" + className="mb-[0.4rem] mr-4 inline h-10 w-10" + ></img> + <Title + text={'Twitch plays Manifold Markets'} + className={'!-my-0 md:block'} + /> + </Row> + <Col className="gap-4"> + <div> + Similar to Twitch channel point predictions, Manifold Markets allows + you to create and feature on stream any question you like with users + predicting to earn play money. + </div> + <div> + The key difference is that Manifold's questions function more like a + stock market and viewers can buy and sell shares over the course of + the event and not just at the start. The market will eventually + resolve to yes or no at which point the winning shareholders will + receive their profit. + </div> + Start playing now by logging in with Google and typing commands in chat! + {twitchUser ? ( + <Button size="xl" color="green" className="btn-disabled self-center"> + Account connected: {twitchUser} + </Button> + ) : isLoading ? ( + <LoadingIndicator spinnerClassName="!w-11 !h-11" /> + ) : ( + <Button + size="xl" + color="gradient" + className="my-4 self-center !px-16" + onClick={getStarted} + > + Start playing + </Button> + )} + <div> + Instead of Twitch channel points we use our play money, mana (m$). All + viewers start with M$1000 and more can be earned for free and then{' '} + <Link href="/charity" className="underline"> + donated to a charity + </Link>{' '} + of their choice at no cost! + </div> + </Col> + </div> + ) +} + +function Subtitle(props: { text: string }) { + const { text } = props + return <div className="text-2xl">{text}</div> +} + +function Command(props: { command: string; desc: string }) { + const { command, desc } = props + return ( + <div> + <p className="inline font-bold">{'!' + command}</p> + {' - '} + <p className="inline">{desc}</p> + </div> + ) +} + +function TwitchChatCommands() { + return ( + <div> + <Title text="Twitch Chat Commands" className="md:block" /> + <Col className="gap-4"> + <Subtitle text="For Chat" /> + <Command command="bet yes#" desc="Bets a # of Mana on yes." /> + <Command command="bet no#" desc="Bets a # of Mana on no." /> + <Command + command="sell" + desc="Sells all shares you own. Using this command causes you to + cash out early before the market resolves. This could be profitable + (if the probability has moved towards the direction you bet) or cause + a loss, although at least you keep some Mana. For maximum profit (but + also risk) it is better to not sell and wait for a favourable + resolution." + /> + <Command command="balance" desc="Shows how much Mana you own." /> + <Command command="allin yes" desc="Bets your entire balance on yes." /> + <Command command="allin no" desc="Bets your entire balance on no." /> + + <Subtitle text="For Mods/Streamer" /> + <Command + command="create <question>" + desc="Creates and features the question. Be careful... this will override any question that is currently featured." + /> + <Command command="resolve yes" desc="Resolves the market as 'Yes'." /> + <Command command="resolve no" desc="Resolves the market as 'No'." /> + <Command + command="resolve n/a" + desc="Resolves the market as 'N/A' and refunds everyone their Mana." + /> + </Col> + </div> + ) +} + +function BotSetupStep(props: { + stepNum: number + buttonName?: string + text: string +}) { + const { stepNum, buttonName, text } = props + return ( + <Col className="flex-1"> + {buttonName && ( + <> + <Button color="green">{buttonName}</Button> + <Spacer h={4} /> + </> + )} + <div> + <p className="inline font-bold">Step {stepNum}. </p> + {text} + </div> + </Col> + ) +} + +function SetUpBot(props: { privateUser?: PrivateUser | null }) { + const { privateUser } = props + const twitchLinked = privateUser?.twitchInfo?.twitchName + return ( + <> + <Title + text={'Set up the bot for your own stream'} + className={'!mb-4 md:block'} + /> + <Col className="gap-4"> + <img + src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" + className="!-my-2" + ></img> + To add the bot to your stream make sure you have logged in then follow + the steps below. + {!twitchLinked && ( + <Button + size="xl" + color="gradient" + className="my-4 self-center !px-16" + // onClick={getStarted} + > + Start playing + </Button> + )} + <div className="flex flex-col gap-6 sm:flex-row"> + <BotSetupStep + stepNum={1} + buttonName={twitchLinked && 'Add bot to channel'} + text="Use the button above to add the bot to your channel. Then mod it by typing in your Twitch chat: /mod ManifoldBot (or whatever you named the bot) If the bot is modded it will not work properly on the backend." + /> + <BotSetupStep + stepNum={2} + buttonName={twitchLinked && 'Overlay link'} + text="Create a new browser source in your streaming software such as OBS. Paste in the above link and resize it to your liking. We recommend setting the size to 400x400." + /> + <BotSetupStep + stepNum={3} + buttonName={twitchLinked && 'Control dock link'} + text="The bot can be controlled entirely through chat. But we made an easy to use control panel. Share the link with your mods or embed it into your OBS as a custom dock." + /> + </div> + </Col> + </> + ) +} + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() + return ( <Page> <SEO @@ -62,7 +250,7 @@ export default function TwitchLandingPage() { <div className="px-4 pt-2 md:mt-0 lg:hidden"> <ManifoldLogo /> </div> - <Col className="items-center"> + {/* <Col className="items-center"> <Col className="max-w-3xl"> <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> <Row className="self-center"> @@ -114,6 +302,12 @@ export default function TwitchLandingPage() { )} </Col> </Col> + </Col> */} + + <Col className="max-w-3xl gap-8 rounded bg-white p-10 text-gray-600 shadow-md sm:mx-auto"> + <TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} /> + <TwitchChatCommands /> + <SetUpBot privateUser={privateUser} /> </Col> </Page> ) diff --git a/web/public/twitch-glitch.svg b/web/public/twitch-glitch.svg new file mode 100644 index 00000000..3120fea7 --- /dev/null +++ b/web/public/twitch-glitch.svg @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill:#9146FF;} +</style> +<title>Asset 2 + + + + + + + + + + + From 256fd89fd29e1f323a77cde85a978570e35268d9 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Fri, 16 Sep 2022 02:38:09 -0500 Subject: [PATCH 09/31] market close fix oopsies (#886) * market close fix --- web/components/contract/contract-details.tsx | 89 +++++++++++--------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index b6623625..36a83f49 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -31,6 +31,7 @@ import { useWindowSize } from 'web/hooks/use-window-size' import { ExtraContractActionsRow } from './extra-contract-actions-row' import { PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' +import { Subtitle } from '../subtitle' export type ShowTime = 'resolve-date' | 'close-date' @@ -427,47 +428,59 @@ function EditableCloseDate(props: { return ( <> - {isEditingCloseTime ? ( - - e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value)} - min={Date.now()} - value={closeDate} - /> - e.stopPropagation()} - onChange={(e) => setCloseHoursMinutes(e.target.value)} - min="00:00" - value={closeHoursMinutes} - /> - - - ) : ( - Date.now() ? 'Trading ends:' : 'Trading ended:'} - time={closeTime} + + + Date.now() ? 'Trading ends:' : 'Trading ended:'} + time={closeTime} + > + isCreator && setIsEditingCloseTime(true)} > - isCreator && setIsEditingCloseTime(true)} - > - {isSameDay ? ( - {fromNow(closeTime)} - ) : isSameYear ? ( - dayJsCloseTime.format('MMM D') - ) : ( - dayJsCloseTime.format('MMM D, YYYY') - )} - - - )} + {isSameDay ? ( + {fromNow(closeTime)} + ) : isSameYear ? ( + dayJsCloseTime.format('MMM D') + ) : ( + dayJsCloseTime.format('MMM D, YYYY') + )} + + ) } From 456aed467c5e0df0b6ea87e00c82901ffe3ebc4e Mon Sep 17 00:00:00 2001 From: FRC Date: Fri, 16 Sep 2022 14:32:15 +0100 Subject: [PATCH 10/31] Move tabs to sidebar (#873) * Move tabs to sidebar * Address all feedback Fix icon names Extract navbar component into a separate function Rm arrow and indentation Move group name under logo Fix visual sidebar stretchy thing Fix visual bug * Extra nits --- web/components/contract-search.tsx | 4 +- web/components/contract-select-modal.tsx | 4 +- web/components/contract/contracts-grid.tsx | 1 + web/components/nav/group-nav-bar.tsx | 94 ++++++++++++ web/components/nav/group-sidebar.tsx | 90 +++++++++++ web/components/nav/nav-bar.tsx | 34 +++-- web/components/nav/sidebar.tsx | 82 ++++++---- web/lib/icons/corner-down-right-icon.tsx | 19 +++ web/pages/experimental/home/index.tsx | 2 + web/pages/group/[...slugs]/index.tsx | 167 ++++++++++++++------- web/pages/home.tsx | 1 + 11 files changed, 389 insertions(+), 109 deletions(-) create mode 100644 web/components/nav/group-nav-bar.tsx create mode 100644 web/components/nav/group-sidebar.tsx create mode 100644 web/lib/icons/corner-down-right-icon.tsx diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 6044178e..a0126b2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -389,9 +389,7 @@ function ContractSearchControls(props: { } return ( - + )} -
+
diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index fcf20f02..3da9a5d5 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -110,6 +110,7 @@ export function CreatorContractsList(props: { return ( void +}) { + const { currentPage } = props + const user = useUser() + + return ( + + ) +} + +function NavBarItem(props: { + item: Item + currentPage: string + onClick: (key: string) => void +}) { + const { item, currentPage } = props + const track = trackCallback( + `group navbar: ${item.trackingEventName ?? item.name}` + ) + + return ( + + ) +} diff --git a/web/components/nav/group-sidebar.tsx b/web/components/nav/group-sidebar.tsx new file mode 100644 index 00000000..3735adc7 --- /dev/null +++ b/web/components/nav/group-sidebar.tsx @@ -0,0 +1,90 @@ +import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { useUser } from 'web/hooks/use-user' +import { ManifoldLogo } from './manifold-logo' +import { ProfileSummary } from './profile-menu' +import React from 'react' +import TrophyIcon from 'web/lib/icons/trophy-icon' +import { SignInButton } from '../sign-in-button' +import CornerDownRightIcon from 'web/lib/icons/corner-down-right-icon' +import NotificationsIcon from '../notifications-icon' +import { SidebarItem } from './sidebar' +import { buildArray } from 'common/util/array' +import { User } from 'common/user' +import { Row } from '../layout/row' +import { Col } from '../layout/col' + +const groupNavigation = [ + { name: 'Markets', key: 'markets', icon: HomeIcon }, + { name: 'About', key: 'about', icon: ClipboardIcon }, + { name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon }, +] + +const generalNavigation = (user?: User | null) => + buildArray( + user && { + name: 'Notifications', + href: `/notifications`, + key: 'notifications', + icon: NotificationsIcon, + } + ) + +export function GroupSidebar(props: { + groupName: string + className?: string + onClick: (key: string) => void + joinOrAddQuestionsButton: React.ReactNode + currentKey: string +}) { + const { className, groupName, currentKey } = props + + const user = useUser() + + return ( + + ) +} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index a07fa0ad..778cdd1a 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -17,6 +17,8 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' +import { User } from 'common/user' + import { PAST_BETS } from 'common/user' function getNavigation() { @@ -35,6 +37,21 @@ const signedOutNavigation = [ { name: 'Explore', href: '/home', icon: SearchIcon }, ] +export const userProfileItem = (user: User) => ({ + name: formatMoney(user.balance), + trackingEventName: 'profile', + href: `/${user.username}?tab=${PAST_BETS}`, + icon: () => ( + + ), +}) + // From https://codepen.io/chris__sev/pen/QWGvYbL export function BottomNavBar() { const [sidebarOpen, setSidebarOpen] = useState(false) @@ -62,20 +79,7 @@ export function BottomNavBar() { ( - - ), - }} + item={userProfileItem(user)} /> )}
+ ( + return buildArray( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ { name: 'Groups', href: '/groups' }, @@ -156,39 +156,59 @@ function getMoreMobileNav() { export type Item = { name: string trackingEventName?: string - href: string + href?: string + key?: string icon?: React.ComponentType<{ className?: string }> } -function SidebarItem(props: { item: Item; currentPage: string }) { - const { item, currentPage } = props - return ( - - - {item.icon && ( - - +export function SidebarItem(props: { + item: Item + currentPage: string + onClick?: (key: string) => void +}) { + const { item, currentPage, onClick } = props + const isCurrentPage = + item.href != null ? item.href === currentPage : item.key === currentPage + + const sidebarItem = ( + + {item.icon && ( + ) + + if (item.href) { + return ( + + {sidebarItem} + + ) + } else { + return onClick ? ( + + ) : ( + <> + ) + } } function SidebarButton(props: { diff --git a/web/lib/icons/corner-down-right-icon.tsx b/web/lib/icons/corner-down-right-icon.tsx new file mode 100644 index 00000000..37d61afa --- /dev/null +++ b/web/lib/icons/corner-down-right-icon.tsx @@ -0,0 +1,19 @@ +export default function CornerDownRightIcon(props: { className?: string }) { + return ( + + + + + ) +} diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index f5734918..2d3270aa 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -114,6 +114,7 @@ function SearchSection(props: { } noControls maxResults={6} + headerClassName="sticky" persistPrefix={`experimental-home-${sort}`} /> @@ -135,6 +136,7 @@ function GroupSection(props: { additionalFilter={{ groupSlug: group.slug }} noControls maxResults={6} + headerClassName="sticky" persistPrefix={`experimental-home-${group.slug}`} /> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 70b06ac5..1edcc638 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { toast } from 'react-hot-toast' +import { toast, Toaster } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' -import { Page } from 'web/components/page' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, @@ -30,7 +29,7 @@ import Custom404 from '../../404' import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { Tabs } from 'web/components/layout/tabs' + import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' @@ -49,6 +48,9 @@ import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' +import { GroupNavBar } from 'web/components/nav/group-nav-bar' +import { ArrowLeftIcon } from '@heroicons/react/solid' +import { GroupSidebar } from 'web/components/nav/group-sidebar' import { SelectMarketsModal } from 'web/components/contract-select-modal' import { BETTORS } from 'common/user' @@ -138,6 +140,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds + const [sidebarIndex, setSidebarIndex] = useState(0) useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -151,7 +154,7 @@ export default function GroupPage(props: { const isMember = user && memberIds.includes(user.id) const maxLeaderboardSize = 50 - const leaderboard = ( + const leaderboardPage = (
) - const aboutTab = ( + const aboutPage = ( {(group.aboutPostId != null || isCreator || isAdmin) && ( ) - const questionsTab = ( - + const questionsPage = ( + <> + {/* align the divs to the right */} +
+
+ +
+
+ + ) - const tabs = [ + const sidebarPages = [ { title: 'Markets', - content: questionsTab, + content: questionsPage, href: groupPath(group.slug, 'markets'), + key: 'markets', }, { title: 'Leaderboards', - content: leaderboard, + content: leaderboardPage, href: groupPath(group.slug, 'leaderboards'), + key: 'leaderboards', }, { title: 'About', - content: aboutTab, + content: aboutPage, href: groupPath(group.slug, 'about'), + key: 'about', }, ] - const tabIndex = tabs - .map((t) => t.title.toLowerCase()) - .indexOf(page ?? 'markets') + const pageContent = sidebarPages[sidebarIndex].content + const onSidebarClick = (key: string) => { + const index = sidebarPages.findIndex((t) => t.key === key) + setSidebarIndex(index) + } + + const joinOrAddQuestionsButton = ( + + ) return ( - - - - -
-
- {group.name} -
-
- -
-
-
- -
-
- - 0 ? tabIndex : 0} - tabs={tabs} - /> -
+ <> + +
+
+ + + + +
+ {pageContent} +
+
+ +
+ + ) +} + +export function TopGroupNavBar(props: { group: Group }) { + return ( +
+
+
+ + + + +
+
+

+ {props.group.name} +

+
+
+
) } @@ -264,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: { group: Group user: User | null | undefined isMember: boolean + className?: string }) { const { group, user, isMember } = props return user && isMember ? ( - + ) : group.anyoneCanJoin ? ( @@ -411,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) { return ( <> -
+
diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 972aa639..50e2c35f 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -26,6 +26,7 @@ const Home = () => { user={user} persistPrefix="home-search" useQueryUrlParam={true} + headerClassName="sticky" /> - ) -} - -function BotConnectButton(props: { - privateUser: PrivateUser | null | undefined -}) { - const { privateUser } = props - const [loading, setLoading] = useState(false) - - const updateBotConnected = (connected: boolean) => async () => { - if (!privateUser) return - const twitchInfo = privateUser.twitchInfo - if (!twitchInfo) return - - const error = connected - ? 'Failed to add bot to your channel' - : 'Failed to remove bot from your channel' - const success = connected - ? 'Added bot to your channel' - : 'Removed bot from your channel' - - setLoading(true) - toast.promise( - updateBotEnabledForUser(privateUser, connected).then(() => - updatePrivateUser(privateUser.id, { - twitchInfo: { ...twitchInfo, botEnabled: connected }, - }) - ), - { loading: 'Updating bot settings...', error, success } - ) - try { - } finally { - setLoading(false) - } - } - - return ( - <> - {privateUser?.twitchInfo?.botEnabled ? ( - - Remove bot from your channel - - ) : ( - - Add bot to your channel - - )} - - ) -} - -export function TwitchPanel() { - const user = useUser() - const privateUser = usePrivateUser() - - const twitchInfo = privateUser?.twitchInfo - const twitchName = twitchInfo?.twitchName - const twitchToken = twitchInfo?.controlToken - - const linkIcon =